diff --git a/.github/workflows/egress-gate.yml b/.github/workflows/egress-gate.yml index b16f6c91..cdb40953 100644 --- a/.github/workflows/egress-gate.yml +++ b/.github/workflows/egress-gate.yml @@ -44,6 +44,11 @@ jobs: echo "UV_CACHE_DIR=$RUNNER_TEMP/egress-gate-uv-cache" >> "$GITHUB_ENV" echo "UV_PROJECT_ENVIRONMENT=$RUNNER_TEMP/egress-gate-venv" >> "$GITHUB_ENV" + - name: Set up Node for the upstream Pi example + uses: actions/setup-node@v6 + with: + node-version: "22.22.2" + - name: Install locked dependencies run: uv sync --frozen diff --git a/.gitignore b/.gitignore index 87fb3d08..99136889 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ temp/ *.temp *.bak .scratch/ +plans/ # Local planning artifacts /plans/ diff --git a/AGENTS.md b/AGENTS.md index bda77c67..a14aa71a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,12 @@ validation commands. When adding a project, also follow ## Repository rules - Make the smallest change that satisfies the task and preserve unrelated work. +- Manage OpenShell middleware with `omm` from + `projects/openshell-middleware-manager/`, including project creation and + protocol, generated binding, and manifest updates. If functionality is missing, + open an issue for the manager; implement a fix only when explicitly requested. + Do not bypass the manager with standalone generators or hand-edit generated + artifacts. - Prefer explicit, clear names and language over concise but ambiguous alternatives. Value concision when it does not reduce clarity. - Use `uv` for Python dependency management, environments, locking, builds, and diff --git a/projects/egress-gate/.gitignore b/projects/egress-gate/.gitignore new file mode 100644 index 00000000..3b9932d3 --- /dev/null +++ b/projects/egress-gate/.gitignore @@ -0,0 +1 @@ +.workspaces/ diff --git a/projects/egress-gate/.openshell-middleware-manifest.json b/projects/egress-gate/.openshell-middleware-manifest.json index 59d9d114..faf55558 100644 --- a/projects/egress-gate/.openshell-middleware-manifest.json +++ b/projects/egress-gate/.openshell-middleware-manifest.json @@ -1,7 +1,7 @@ { - "openshell_version": "v0.0.97", - "proto_source": "https://raw.githubusercontent.com/NVIDIA/OpenShell/v0.0.97/proto/supervisor_middleware.proto", - "proto_sha256": "e9d5a992ff5b50a33e9625176aaf6df8496d6774aa2ef3afe5cae7bc83c01105", + "openshell_version": "v0.0.116", + "proto_source": "https://raw.githubusercontent.com/NVIDIA/OpenShell/v0.0.116/proto/supervisor_middleware.proto", + "proto_sha256": "d96a963321c74c261a912dcd0b8cda690741b32b8c3d90ff3ef38dafe6681bad", "languages": [ "python" ], diff --git a/projects/egress-gate/AGENTS.md b/projects/egress-gate/AGENTS.md index aaeb8db9..515cacf4 100644 --- a/projects/egress-gate/AGENTS.md +++ b/projects/egress-gate/AGENTS.md @@ -18,6 +18,11 @@ Run focused tests while working and `make check` before handoff. ## Engineering approach +- Update the OpenShell protocol, generated bindings and manifest only through + `openshell-middleware-manager`. `scripts/generate-bindings.sh` delegates to it. + Open an issue for generator gaps; implement a fix only when explicitly + requested. Do not add a separate protoc workflow or edit generated artifacts + by hand. - Backwards compatibility with the removed legacy policy API is not a concern. Do not restore old schemas, imports, names, aliases, or obsolete pipeline terms. - Gates are trusted application code. Capabilities enforce declared output diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index 1471f75e..bea22ebe 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -49,6 +49,13 @@ Use `0.0.0.0` only when the OpenShell supervisor must reach the service across network namespaces. The development server uses plaintext gRPC. Restrict its listen port to trusted networks. +Ordinary HTTP middleware is the default. To require admission receipts, pass +`--admission-config /absolute/path/to/admission.json`. That operator-owned +configuration enables the additional authenticated HTTPS admission API and +TLS/JWT authentication for the middleware listener. See the +[no-fork Pi example](examples/pi-attested-admission/README.md) for a runnable +upstream deployment, trust boundaries, and supported scope. + ## Policy shape The registry builds an exact strict schema from installed gate types: @@ -107,7 +114,9 @@ server = EgressGateServer( server.serve_sync("127.0.0.1:50051") ``` -In this example, `timeout_middleware_processing` gives each evaluation 10 +Pass an `AdmissionServerConfig` as `admission=` to enable the optional +receipt-required deployment. In this ordinary middleware example, +`timeout_middleware_processing` gives each evaluation 10 seconds. Omitting it uses the one-second service default. The value is expressed in seconds, must be at least 10 milliseconds, and must resolve to whole milliseconds. The service passes one resulting `Timeout` through slot @@ -136,11 +145,19 @@ timeout failures must deny. - [Architecture](https://github.com/NVIDIA/OpenShell-Research/blob/main/projects/egress-gate/docs/architecture/index.md) - [Limits and failures](https://github.com/NVIDIA/OpenShell-Research/blob/main/projects/egress-gate/docs/reference/limits-and-failures.md) - [Regex redaction composition](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/regex-redaction) +- [Pi attested-admission example](examples/pi-attested-admission/README.md) - [Function-based custom gate](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/custom-gate) - [Class-based custom gate](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/class-based-gate) ## Development +Full checks also require Node 22.19+ and npm for the locked upstream Pi example. +The first run installs its JavaScript dependencies. `make check` builds the Pi +application before running Python tests, including the local cross-language +integration test. To run that test directly, first run +`npm --prefix examples/pi-attested-admission/pi-harness ci --ignore-scripts` and +`npm --prefix examples/pi-attested-admission/pi-harness run build`. + ```bash make help make test PYTEST_ARGS="tests/gates tests/test_request_processor.py" @@ -149,3 +166,15 @@ make check Only `service/` imports generated protobuf/gRPC bindings. Do not edit `plans/egress-gate-refactor.md` as part of implementation work. + +Update the protocol and bindings only through the repository's +`openshell-middleware-manager` package: + +```bash +scripts/generate-bindings.sh +``` + +This delegates to `omm update` for the pinned OpenShell release, with `make check` +as validation. The manager downloads the proto, regenerates bindings with an +isolated compiler, and updates the lockfile and manifest together only after +checks pass. Do not edit these generated artifacts or run protoc separately. diff --git a/projects/egress-gate/docs/architecture/admission.md b/projects/egress-gate/docs/architecture/admission.md new file mode 100644 index 00000000..81a3996f --- /dev/null +++ b/projects/egress-gate/docs/architecture/admission.md @@ -0,0 +1,231 @@ +--- +title: Admission without harness forks +description: Application-owned history admission and standard OpenShell egress verification. +agent_markdown: true +--- + +# Admission without harness forks + +The [runnable Pi example](https://github.com/NVIDIA/OpenShell-Research/tree/johnny/pi-attested-admission/projects/egress-gate/examples/pi-attested-admission) +uses published Pi 0.85.1 packages with an existing OpenShell gateway (0.0.116 +is the tested protocol baseline). No upstream library, +runtime, protobuf, or CLI patches are required. Its smaller surface is a +Pi application using the native TUI, not full stock Pi CLI parity. + +## The two boundaries + +Network enforcement cannot undo an earlier local-history write. For example, +a tool may return sensitive text; blocking the next model request leaves that +text in the transcript if it was already appended. Conversely, a cooperative +application check alone does not prevent another client making a raw request. + +```text +Candidate --> admission policy --> approved history --> next model context + | | + deny signed receipt + | | + no history write OpenShell inspects request + | + verify + request policy + | + credential --> model +``` + +There are two distinct properties: + +1. **Local insertion:** the controlled application asks before writing to live + conversation state or Pi's SessionManager. Only approved/replacement content + enters history. Transient candidate buffers necessarily exist. +2. **Egress:** an external verifier checks a service-signed receipt against the + actual intercepted request before OpenShell attaches provider credentials. + +A receipt proves service approval of covered content, **not** that a particular +extension ran or that every historical append was checked. A compromised +application or same-authority code can violate local storage integrity. + +## One history owner + +The application supplies an admission-controlled `Agent` through the public +`AgentSessionConfig.agent` SDK seam. Pi's native `InteractiveMode` and session +runtime use that agent. It checks candidates before changing live state or +emitting message events; the native `AgentSession` alone persists those approved +events. It does not depend on late message notifications or remove content +after insertion. + +| Candidate | What is admitted before writing | +| --- | --- | +| User / explicit skill | Final text after supported skill rendering | +| Project context | Loaded project instructions and model-visible skill metadata | +| Assistant | Finalized answer/reasoning text, replay metadata and tool calls | +| Tool | Final output, including invalid-argument, missing-tool and execution errors | +| Compaction | Complete summary, for both manual and automatic triggers | + +Tool-call fields are inspectable but immutable: attempted executable-argument +redaction fails closed. Reasoning text is admitted along with immutable replay +metadata; plain reasoning can be redacted, but reasoning with signed or structured replay metadata +cannot be changed independently. Allowed native messages retain their block +order, signatures and metadata. Unsupported images/provider state is rejected, +not stored as unchecked sidecars. Tool details and progress are not published; +the TUI receives only admitted final results, with activity indicators while +waiting. Editor drafts and pending input queues are distinct from admitted +conversation history. +Bash uses a bounded public operations wrapper to avoid Pi's output-log spill. + +On a denied tool result, the application stops model calls. It submits fixed, +content-free failures for outstanding calls through the same boundary. If those +cannot be admitted, the session stops without claiming crash recovery. +Tool side effects themselves are not reversible by result admission. + +Compaction uses Pi's public `compact()` computation and native recent-context +retention, including split turns. Its model requests pass through the +receipt-wrapped stream. The complete returned summary, including file-operation +text, is admitted before the trusted `session_before_compact` extension returns +it. Unchecked `details` are omitted. Failure explicitly cancels; it never falls +through to an unchecked default summary. Denial leaves the preceding context and +file unchanged. + +Manual and automatic compaction use that same checked path. Native automatic +compaction also runs between tool turns; when enabled, overflow gets at most one +compact/retry. Disabling it disables automatic overflow recovery, not manual +compaction. Short sessions may have nothing to compact under Pi's retention +budget. Transient failures are not automatically retried, and unchecked provider +errors stay out of history. Old approved entries remain in the append-only JSONL +file. + +Pi's synchronous system-prompt rebuilds are staged as private candidates. Public +agent/session state retains the last approved system prompt, including while a +new user candidate is pending or denied. Provider calls use the newly approved +snapshot. In-memory TUI preferences survive `/new`; conversation state and its +provider session identity do not carry over. + +One cwd scopes resources, tools and storage. It is not confinement; OpenShell +filesystem policy is. The application is installed outside the writable project +and does not load third-party extensions or implicitly resume saved transcripts. + +## Service, identity, and receipts + +The service adds one bounded `POST /v1/admission` HTTPS endpoint alongside +ordinary OpenShell middleware gRPC. It reuses the transport-neutral admission +models, fixed Pi shape validation, policy pipeline and receipt authority. Provider validation +supports Chat Completions only and extracts ordered user/tool entries directly; +there is no second normalized model-request representation. Branching, extension +messages and standalone bash-execution envelopes are not admission APIs in this +POC. Bash tool output uses the same tool-result boundary as other tools. + +Preparation discovers the existing gateway's public Ed25519 signing key and +issuer using its HTTPS discovery endpoints and the CLI's saved mTLS credentials. +Only the gateway name, reachable service host and model key are supplied by the +operator. Discovery requires one published signing key and refuses plaintext, +cross-origin key URLs and untrusted TLS; browser/edge-login gateways are outside +this POC helper's scope. Host setup generates service TLS, one admission bearer +credential, provider destination and policy; setup reads the actual sandbox ID. +The operator supplies Pi's native `models.json` catalog. Preparation selects one +declared model (using `PI_MODEL=provider/model` when there are several), stages +only that model and its provider settings without provider API-key configuration, +and derives the endpoint policy from it. Pi's own parser resolves model defaults +and compatibility settings. Credential and model-cache stores are in memory; +the image does not need a writable `auth.json`. Changing the selection requires +repreparing and recreating the demo, not live switching. +For local installer-managed gateways, `register` adds the demo entry and restarts +the service; `cleanup` removes it only if it still matches the recorded entry. +Other deployments use operator-managed registration. The demo does not generate +gateway credentials or download OpenShell binaries. +The sandbox cannot select its authoritative +identity or submit a policy. The single host-owned identity file is populated +after sandbox creation; until then admission is unavailable. There is no +registration API or new credential broker. + +Upstream OpenShell delivers endpoint-bound credential placeholders. Real secrets +stay outside the sandbox. A placeholder is still an application-accessible +capability, not process attestation. Removing it from tool child environments +is hygiene, not isolation from malicious same-authority code. + +The gRPC boundary verifies the existing EdDSA extension JWT against the operator's +pinned gateway public key, issuer, audience and token type. HTTP evaluations also +require a supervisor caller whose sandbox ID matches request context. +Both listeners use verified TLS. The gateway advertises only the standard HTTP +middleware contract, not fork-specific harness RPCs. + +Before every model call, including tool continuation and summarization, the +application asks approval for the ordered user/tool text projection and sends +the resulting base64url receipt in one `x-egress-admission` header. + +At egress the verifier: + +1. requires exactly one well-formed receipt; +2. parses the supported provider body and derives the ordered user/tool text; +3. checks signature, key, expiry, sandbox, destination, policy and content hash; +4. runs the configured request gates; +5. rechecks that mutations did not change receipt-covered content; and +6. removes the receipt header before forwarding. + +Receipts use the internal `agent-attestation.v2` claim format. +Its ephemeral service signing key and five-minute lifetime permit identical +retries, not one-time delivery. Restarting the service invalidates old receipts. + +The receipt does **not** sign every byte, system/assistant messages, model +parameters or tool schemas. Those remain subject to the normal request policy. +Final-context replacement is rejected by the application so outbound text cannot +silently diverge from its approved history. There is one final middleware +attachment; adding a later content-mutating middleware breaks that assumption. + +## Deliberate POC limits + +One text-only OpenAI-compatible Chat Completions model, sequential tools, fresh +sessions and explicit skills. The native TUI supports admitted chat, tool cards, +steering/follow-ups, compaction and `/new`. Direct `!`/`!!` shell execution, +custom extension messages, import/resume, branching, renaming, model switching, +and resource reload are blocked at their public session/runtime entry points. +Shell work through the model's bash tool remains supported. +OpenRouter reasoning requests and replay are supported without a Pi patch. +No RPC mode, arbitrary extensions, images, WebSockets, transport +switching, or crash resume. +Network policy allows only the chosen POST model path and separately scopes the +admission endpoint. Unknown shapes fail closed; admission requests do not +recursively require model receipts. + +Ordinary HTTP-only Egress Gate remains available with `egress-gate serve`. +Only `--admission-config` selects the receipt-required deployment. + +## Evidence and Dev Note narrative + +The implementation's deterministic tests cover pending/denied candidates before +both live and durable writes, accepted replacements, real tool continuations, +and the shared manual/auto compaction path. Service tests cover authenticated +caller binding, upstream RPCs, receipts, policy decisions and header removal. +A cross-language integration test also runs the actual Pi serializer and HTTP +admission client against local HTTPS admission and provider endpoints. It checks +redaction, skills, a real read-tool continuation, both compaction paths and +receipt verification over authenticated gRPC. A pseudo-terminal variant drives +the actual Pi TUI, including tool expansion, manual compaction, denial and +`/new`, then inspects the saved JSONL. Only provider responses are +controlled test data; it does not substitute for live OpenShell acceptance. +The example's `demo.sh verify` is a separate real-model end-to-end acceptance +command, not a simulated demonstration. Its success must be observed, not inferred +from unit tests. + +The deterministic integration uses HTTPS admission/provider endpoints and gateway +JWT authentication over a local insecure gRPC channel. It does not exercise +production TLS gRPC startup or a live OpenShell gateway. + +**The native-TUI workflow still needs live OpenShell/real-model acceptance.** +Run `demo.sh verify` with a valid provider credential before describing the +deployment as e2e-verified. The verifier deliberately lowers retention thresholds +for its short conversations; the interactive launcher keeps native Pi defaults. +The pinned package/image versions are recorded in the example's package lock, +Dockerfile and middleware manifest. Current validation results belong in the PR, +not a second historical log here. + +A useful Dev Note, **“Gating at the network layer is not enough,”** can follow: + +1. A network-denied tool result can still contaminate local history. +2. Move the local decision before the write; show deny and replacement in JSONL. +3. Keep a real agent: tools, skills and compaction all use that one boundary. +4. Demonstrate a raw provider request bypassing the application but being denied + by OpenShell because it has no approval receipt. +5. Explain the complementary guarantees and honestly show their limits. + +The takeaway is not “the network boundary is insufficient security.” It is that +local-history integrity and outbound-request authorization happen at different +times and require different enforcement points. No fork makes the composition +easier to reproduce; it does not make the guarantees stronger by itself. diff --git a/projects/egress-gate/docs/architecture/index.md b/projects/egress-gate/docs/architecture/index.md index 81ebd3ac..8d7dd044 100644 --- a/projects/egress-gate/docs/architecture/index.md +++ b/projects/egress-gate/docs/architecture/index.md @@ -71,4 +71,6 @@ the shared deadline checks. A failed candidate leaves the existing policy unchanged. Gate instances are reused across worker threads, so per-request state must remain local to `evaluate`. -See [Request lifecycle](request-lifecycle.md) and [Service boundary](service-boundary.md). +See [Request lifecycle](request-lifecycle.md), +[Service boundary](service-boundary.md), and +[Managed harness admission](admission.md). diff --git a/projects/egress-gate/docs/architecture/service-boundary.md b/projects/egress-gate/docs/architecture/service-boundary.md index 686cd493..7c8c7eb6 100644 --- a/projects/egress-gate/docs/architecture/service-boundary.md +++ b/projects/egress-gate/docs/architecture/service-boundary.md @@ -10,6 +10,10 @@ The `service/` package is the only handwritten package that imports OpenShell protobuf/gRPC bindings. It owns exact encoded wire limits and transport status mapping. Domain models own protobuf-free invariants. +The optional admission deployment adds a bounded HTTPS candidate endpoint and +authenticates standard gRPC calls using OpenShell's existing extension JWTs. +It does not add a protobuf RPC. See [Admission without forks](admission.md). + The OpenShell supervisor owns the intercepted request. Egress Gate receives its request data over gRPC and works with local immutable `HttpRequest` snapshots. The Egress Gate service adapter returns a decision and final mutations; the diff --git a/projects/egress-gate/examples/pi-attested-admission/.env.example b/projects/egress-gate/examples/pi-attested-admission/.env.example new file mode 100644 index 00000000..59380f72 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/.env.example @@ -0,0 +1,12 @@ +# Existing HTTPS/mTLS gateway registered in your OpenShell CLI (gateway list). +OPENSHELL_GATEWAY=your-gateway +# Egress Gate hostname or IPv4 address reachable from gateway AND sandbox. +# Use reachable DNS or a LAN IPv4 address; Docker-only names may fail on the host. +EGRESS_GATE_HOST=your-service-host +# Required only when models.json declares more than one model. +# PI_MODEL=openrouter/z-ai/glm-5.3-flash +# Optional Pi preference, forwarded to launch/verify. Omit for Pi's default. +# PI_CACHE_RETENTION=long +# Key for the provider in models.json (OpenRouter in the supplied example). +# Never copied into the image. Create an OpenRouter key at https://openrouter.ai/settings/keys. +PI_MODEL_API_KEY=your-provider-key diff --git a/projects/egress-gate/examples/pi-attested-admission/.gitignore b/projects/egress-gate/examples/pi-attested-admission/.gitignore new file mode 100644 index 00000000..16fb84a6 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/.gitignore @@ -0,0 +1,4 @@ +# Operator-owned model configuration, like the already ignored .env. +# Keep the previous operator filename ignored during migration. +/model.json +/models.json diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md new file mode 100644 index 00000000..cacd8383 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -0,0 +1,332 @@ +# Pi admission without forks + +A small, real Pi-powered coding assistant runs in unmodified OpenShell. +Egress Gate approves content **before** it enters the assistant's live history +or Pi's saved session. It can deny text or replace it; a second check at the +network boundary prevents sending an unapproved user/tool context. + +This launches **Pi's native TUI** through its public SDK: the normal editor, +chat, tool cards, shortcuts, and compaction UI. An application-owned agent +checks content before publishing it to Pi's session. It keeps real tools, +tool continuations, project instructions, explicit skills, and manual/automatic +compaction. Neither Pi nor OpenShell needs a patch. + +## Try it + +You need an **existing HTTPS/mTLS OpenShell gateway** and the `openshell` +CLI configured with its name. Use OpenShell **0.0.116** (the tested protocol +baseline) or a compatible newer release with middleware authentication and +proxy credential delivery. This example does not install or start OpenShell. + +Also needed: Bash, Python 3.11+, uv 0.11+, Docker, and a provider API key with +available credit/quota for a tool-capable Chat Completions model. This demo uses +text input/output, including model reasoning. The simple image workflow +assumes your gateway's Docker +driver uses the same Docker daemon as `docker build`. Remote drivers/image +distribution are outside this example. The same commands below work with a local, +installer-managed gateway on macOS or Linux; the helper selects its config and +service manager automatically. Remote and custom gateway deployments require +operator-managed registration (see below). + +For a fresh setup, from `projects/egress-gate/examples/pi-attested-admission/` +(keep your existing `.env` and `models.json` when updating an installed demo): + +```sh +openshell gateway list +cp .env.example .env +cp models.json.example models.json +# Edit .env: gateway name, reachable service host, and your provider API key. +# The model template is ready for OpenRouter; edit it only to choose another model/provider. +./demo.sh prepare +``` + +The template uses [GLM-5.3-Flash on OpenRouter](https://openrouter.ai/z-ai/glm-5.3-flash), +which supports tool calling and always-on reasoning. Put an [OpenRouter API key](https://openrouter.ai/settings/keys) +in `PI_MODEL_API_KEY` in `.env`. No NVIDIA account, inference hub, or +OpenRouter-specific SDK is required. OpenRouter's [Chat Completions API](https://openrouter.ai/docs/quickstart) +uses `https://openrouter.ai/api/v1`; its attribution headers are optional and +are not needed here. Usage is billed by your provider. + +Create your own `models.json` from [models.json.example](models.json.example). +It uses **Pi's native `{"providers": {...}}` catalog format**. Add as many providers +and models as you like. One declared model is selected automatically; with more +than one, set `PI_MODEL=provider/model` in `.env` (for example, +`PI_MODEL=openrouter/z-ai/glm-5.3-flash`). Model IDs can contain slashes. +Leave `PI_MODEL` unset for the single-model template. To use another provider, +replace the provider name, HTTPS base URL, model ID and model limits, and supply +that provider's key in the same `PI_MODEL_API_KEY` variable. No script or policy +edits are needed: preparation derives the permitted host and request path from +the selected model. Use a tool-capable model through an +OpenAI-compatible Chat Completions endpoint. Set: + +- `providers..models`: your models, each with an `id` and optional `name`. +- `providers..baseUrl`: the HTTPS API base, such as `https://your-provider.example/v1`; + the application appends `/chat/completions`. +- `contextWindow` and `maxTokens`: the model's context limit and your desired + response limit, in tokens. Pi applies its normal context-fit adjustment; the + application adds no response-token cap. The template uses the model's published + context window and chooses a 32,768-token output budget, shared by reasoning + and the answer; adjust it as desired. +- `samplingParams`: Pi forwards model sampling settings such as `temperature` + and `top_p` without application overrides. The gate's supported request shape + still applies; unknown provider-specific fields are rejected, not dropped. +- `compat.maxTokensField`: the field your provider accepts (`max_tokens` or + `max_completion_tokens`). The other compatibility settings are conservative + defaults; adjust them if your endpoint requires it. + +Keep `api` and text-only `input` as shown. Set `reasoning` to match your model. +GLM-5.3-Flash cannot disable thinking; its [documented levels](https://docs.z.ai/guides/vlm/glm-5.3-flash) +are `low`, `high`, and `max`. The template's native Pi `thinkingLevelMap` exposes +those levels. Pi clamps its default `medium` to `high`; its normal thinking +controls can select another supported level. Ordinary requests and compaction +use the selected level. +The zero `cost` values disable cost estimates; provider usage is not free. +Put the API key only in `.env`, never in `models.json`. +Both files are ignored by Git. Preparation copies **only the selected model** and +its provider settings into the image, removing provider `apiKey` configuration. +Pi resolves its defaults and compatibility settings; a model-level `baseUrl` or +`api` takes precedence over the provider setting. Declare both values explicitly +at one of those levels. This POC does not support OAuth or custom headers. +Credentials and model caches stay in memory; no writable `/app/agent/auth.json` +is needed. +Compaction uses Pi's normal summary budget, bounded by the model's `maxTokens`. + +Prompt caching stays under Pi's control: the gate accepts its cache keys, +retention fields, and compatibility-generated `cache_control` metadata without +rewriting them. To request Pi's longer retention where supported, optionally set +`PI_CACHE_RETENTION=long` in `.env` or export it before `launch`/`verify`; leave it +unset for Pi's default. No rebuild is needed. Pi disables cache retention for +one-off compaction requests. Redaction and compaction can change prompt content +and therefore cache hits; approval receipts are not added to the prompt. + +The catalog can contain many models, but each prepared demo uses **one**. To change +the selection, run `./demo.sh cleanup`, stop `serve`, update `PI_MODEL` and its key, +then repeat `prepare`, `serve`, `register`, and `setup`. Live model switching is +disabled because OpenShell's policy and admission receipts are bound to the +prepared endpoint. + +`prepare` reads the selected endpoint from `openshell gateway list --output json` +and discovers its issuer and public signing key over verified HTTPS. It reuses +the CLI's existing client certificates under +`${XDG_CONFIG_HOME:-$HOME/.config}/openshell/gateways//mtls/`. +You do not supply signing keys, an issuer, or certificate paths. This POC supports +registered mTLS gateways; plaintext and browser/edge-login gateways are not +supported by this discovery helper. It never disables TLS verification. + +`EGRESS_GATE_HOST` must resolve to this service from **both gateway and sandbox**: +use a reachable DNS name or IPv4 address, without a scheme or port. +`host.docker.internal` may work inside Docker Desktop containers but fail to +resolve for a gateway running directly on the host. Use the service machine's +reachable LAN IPv4 address or a DNS name that works from both places. Do not +use `localhost` when callers are in containers. The gateway connects to the +middleware during startup, so an unreachable address can prevent it starting. + +Preparation builds the pinned Pi **0.85.1** image and writes service TLS, +policy and provider profiles under `../../.workspaces/pi-admission/`. +The discovered public key is saved there as `gateway-public.pem`. No fork clones, +gateway binaries or gateway private keys are created. + +Keep Egress Gate running in one terminal: + +```sh +./demo.sh serve +``` + +In a second terminal, from this directory: + +```sh +./demo.sh register +./demo.sh setup +./demo.sh launch +``` + +`register` finds the local gateway's config, adds only `pi-egress`, restarts the +gateway through its service manager, and waits for gateway health. It preserves +unrelated settings and refuses to overwrite a registration it did not create. +If the installation uses built-in defaults without a config file, it creates a +minimal one for this registration. You do not need to choose a config path or +run service-manager commands yourself. +**Registration and cleanup briefly interrupt this gateway.** Coordinate this +if anyone else uses it. No OpenShell changes or additional `.env` settings are +needed for the standard installation. + +Automatic service handling supports Homebrew and the DEB/RPM user service. +Other service layouts (including Snap and custom config overrides) are +operator-managed; the helper does not guess their config or request root access. + +For other deployments, `./demo.sh registration` only **prints** the TOML entry; +it does not install it. The gateway operator must merge it into the active config, +make the public `tls/ca.crt` accessible at `tls_ca_cert_path`, and restart the +gateway. Those operator-managed registrations must also be removed manually. + +`setup` displays the selected gateway and creates the `pi-admission` sandbox +and its two provider profiles/instances. Reserve those names for this demo. +Egress Gate listens on **50051** (authenticated middleware gRPC) and **5443** +(authenticated admission HTTPS). The gateway must reach the first port and the +sandbox must reach the second at `EGRESS_GATE_HOST`; allow those connections +through the service host's firewall. Restrict access to the gateway/sandbox network. +If setup stops partway through, run `cleanup` before retrying `register` and +`setup`; do not repeatedly import the same provider profiles. Keep `serve` +running until cleanup has removed the gateway registration. + +Every action can print its commands without executing them, loading `.env`, +or printing secrets: + +```sh +./demo.sh --print prepare +./demo.sh --print register +./demo.sh --print setup +./demo.sh --print launch +./demo.sh --print cleanup +``` + +Print mode uses exported configuration or placeholders because it does not load +`.env`. Real commands use `.env`; credentials are passed by environment-variable +name. The generated `admission.json` contains a private admission token and must +remain outside the image and repository. + +**Validation status:** local SDK and native-TUI integration tests pass, including +real admission HTTP/RPC traffic, tool output, compaction, denial and saved JSONL. +Provider responses in those tests are controlled fixtures. The updated native-TUI +workflow still needs acceptance testing with a running OpenShell gateway and a +real model, including the OpenRouter example. Registration lifecycle tests cover +both supported service managers. +`./demo.sh verify` below is that separate real-model acceptance check. + +## What to try + +Type these into the running application: + +```text +Hello. Briefly describe what you can do. +Please repeat REDACT_THIS. +DENY_THIS +/skill:review +/compact +/new +/quit +``` + +`DENY_THIS` and `REDACT_THIS` are harmless, literal demonstration markers defined +in [policy.yaml](policy.yaml), not magic Pi/OpenShell features or real secrets. +The first is denied; the second becomes `[REDACTED]` before insertion. +Policy detection is only as good as its configured rules. + +The selected project is `/sandbox/project`, copied from [project/](project/). +Pi's resource loader and tools use that directory; saved sessions live separately +under `/sandbox/sessions`. +Its `AGENTS.md` and skill metadata are admitted as system context. +`/skill:review` loads and renders the actual skill before user-message admission. +The skill asks the model to read the real `notes.txt`; that result is admitted +before the next model call. `cwd` is convenient scoping, not an access-control +boundary: OpenShell's filesystem policy supplies that boundary. + +Responses and tool output are buffered until approved, rather than streamed +unchecked into the transcript. Pi still shows activity while waiting. +Reasoning text and its provider replay metadata are admitted before the thinking +block reaches live history or JSONL. On allow, native messages are preserved, +including block order and signatures. Plain reasoning can be redacted; +reasoning carrying signed or structured replay metadata is immutable, so a policy attempting to +redact it denies that candidate instead of breaking replay. Opaque metadata is +inspected as supplied, not decrypted. The same applies +to executable tool-call fields. Text redaction across multiple assistant/user +blocks is rejected when the joined projection cannot identify the original block. +The launch and verification commands pass `--disable-warning=UNDICI-EHPA` +directly to Node to hide only the experimental `EnvHttpProxyAgent` notice; +other warnings and errors remain visible. This does not depend on Docker image +environment variables being inherited by `sandbox exec`. +Use Ctrl+O to expand tool output and `/session` to inspect session information; +Pi saves JSONL under `/sandbox/sessions`. +Preferences changed in the TUI survive `/new` within this running application; +they are not saved across launcher restarts. Provider session-affinity/cache +identity follows Pi's normal behavior, including a new identity for `/new`. +Compaction uses Pi's native retention policy and summary computation; only the +admitted final summary enters history. Older **approved** entries remain in the +append-only file. Automatic compaction uses the same checked path at Pi's +context thresholds, including between tool turns. Esc cancels the current operation. Steering and follow-up inputs are +admitted after skill expansion, before joining the transcript. Drafts and pending +input queues are not approved history. An unfinished tool batch that cannot be +safely closed requires `/new`. +Turning automatic compaction off also disables automatic overflow recovery; +manual `/compact` remains available. Pi can split a long turn, but reports +“Nothing to compact” when the conversation fits its recent-context budget. +Transient chat and summary failures stop the operation rather than automatically +retrying; unchecked provider errors are never appended to history. + +This POC deliberately blocks `!`/`!!`, resume/import, branching, renaming, +model switching, and resource reload: these need additional handling before +they can be safely enabled. Ask the model to use the **bash tool** for shell +work. Arbitrary extensions are not loaded. Tool cards display admitted results, +not unchecked progress or extra tool metadata such as edit diffs. + +## Verify and clean up + +```sh +./demo.sh verify +./demo.sh cleanup +``` + +Verification uses the **real configured model** and can incur several model +calls and normal provider charges. It checks a raw request without a receipt, +deny/redact history, a real skill/tool continuation, and manual and automatic +compaction. The verifier lowers retention thresholds for its short test conversations; +the interactive launcher retains Pi's defaults. It exits unsuccessfully on any missing capability or failed check; +it does not skip checks or substitute a mock model. Deterministic failure and +pending-admission tests live in [pi-harness/test/](pi-harness/test/). + +Cleanup deletes only this demo sandbox and its provider instances/profiles, +then removes the registration created by `register` and restarts the gateway. +**Sandbox files and sessions are deleted and are not recoverable by this script.** +Copy out anything wanted first, then stop `serve` with Ctrl-C. Host configuration +and the local Docker image remain for reuse. To remove only the registration +(without deleting the sandbox), use `./demo.sh unregister`. This also works +after a failed registration restart; fix the service problem and retry. If an +operator changed the registration, cleanup refuses to remove it. Older ownership +records without the installed entry also require operator-managed removal. + +For source/model/policy changes, clean up the old demo sandbox, run `prepare`, +restart `serve`, then run `register`, `setup`, and `launch`. This rebuild is +required because existing sandboxes continue using their old image. Valid service +certificates are reused for the same host. After 30 days or a host change, +`prepare` generates new service TLS: rerun `register` to reload the new CA before setup (other deployments must +update their gateway-visible CA and restart manually). Refresh the +gateway identity by rerunning `prepare` and restarting `serve` if the gateway +rotates its signing key. Discovery currently expects one published signing key. + +## How the pieces fit + +[agent.ts](pi-harness/src/agent.ts) approves candidates before publishing message +events; Pi's native session is the only history writer. +[session.ts](pi-harness/src/session.ts) connects that agent to the native TUI, +checked compaction, and guards on unsupported write paths. +[admission.ts](pi-harness/src/admission.ts) calls the external service and +attaches receipts to model requests. OpenShell invokes Egress Gate to verify +those requests before adding real provider credentials. + +[prepare.py](prepare.py) runs on the trusted host, not in the sandbox. It owns +policy, TLS and credential provisioning. Setup binds the real sandbox ID. +See the [architecture guide](../../docs/architecture/admission.md) for the data +flow, exact receipt contract and verification evidence. + +## Honest boundaries + +- The local history property holds for this controlled application's write + paths. It is not protection against a compromised application or arbitrary + same-authority code rewriting local files. +- Receipts bind the **ordered user/tool text projection**, destination, sandbox, + policy and expiry—not the full HTTP body, system prompt, assistant history, + model parameters, or proof that an extension ran. Request policy still checks + the intercepted body. Receipts are reusable for identical content for up to + five minutes; service restarts invalidate them. +- One text-only Chat Completions model, sequential tools, and new sessions. + The real TUI is used, but not every stock CLI feature is supported. + No RPC mode, third-party extensions, resume/branching, images, WebSockets, + or model switching. Unsupported content fails closed. +- Redaction can change ordinary text, not executable tool arguments or call + identifiers. Admitting a tool result cannot reverse tool side effects. + Bash output is bounded before Pi's unchecked spill-to-file behavior. +- There is one final Egress Gate middleware binding. Do not append another + middleware that rewrites receipt-covered content afterward. + +See the [architecture and evidence guide](../../docs/architecture/admission.md) +for the exact contract and the Dev Note narrative. diff --git a/projects/egress-gate/examples/pi-attested-admission/bind-sandbox.py b/projects/egress-gate/examples/pi-attested-admission/bind-sandbox.py new file mode 100644 index 00000000..68a66c9e --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/bind-sandbox.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Bind the service's demo credential to an operator-observed sandbox ID.""" + +import argparse +import json +import sys +from pathlib import Path + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--state", type=Path, required=True) + args = parser.parse_args() + sandbox = json.load(sys.stdin) + identifier = sandbox["id"] + if not isinstance(identifier, str) or not identifier: + raise ValueError("OpenShell did not return a sandbox ID") + (args.state / "sandbox-id").write_text(identifier + "\n") + print("Admission identity bound. Run ./demo.sh launch or ./demo.sh verify.") + + +if __name__ == "__main__": + main() diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh new file mode 100755 index 00000000..5e0cca7c --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail +set +x # Never trace populated credential variables. +umask 077 +example=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +project=$(cd -- "$example/../.." && pwd) +state=$project/.workspaces/pi-admission +print_only=false +if [[ ${1:-} == --print ]]; then print_only=true; shift; fi +action=${1:-help} +# .env is trusted operator input. Print mode never executes it. +if ! $print_only && [[ -f $example/.env ]]; then + set -a + source "$example/.env" + set +a +fi +service_host=${EGRESS_GATE_HOST:-YOUR_SERVICE_HOST} +gateway=${OPENSHELL_GATEWAY:-YOUR_GATEWAY} +openshell=(openshell --gateway "$gateway") +pi_env=(/usr/bin/env) +if [[ -n ${PI_CACHE_RETENTION:-} ]]; then + pi_env+=("PI_CACHE_RETENTION=$PI_CACHE_RETENTION") +fi +run() { + if $print_only; then printf '%q ' "$@"; printf '\n'; else "$@"; fi +} +delete_if_present() { + local resource=$1 output status + shift + if $print_only; then run "$@"; return; fi + if output=$("$@" 2>&1); then + printf '%s\n' "$output" + else + status=$? + # Older OpenShell releases return gRPC NotFound for an absent resource. + if [[ $output == *"code: 'Some requested entity was not found'"* && + $output == *"message: \"$resource not found\""* ]]; then + printf '%s already absent; continuing cleanup.\n' "$resource" + else + printf '%s\n' "$output" >&2 + return "$status" + fi + fi +} +registration() { + run uv run --frozen python "$example/gateway-registration.py" "$1" --state "$state" --gateway "$gateway" + if $print_only; then + printf '# Helper edits only pi-egress, waits for health, and internally runs: ' + case "$OSTYPE" in + darwin*) run brew services restart openshell ;; + linux*) run systemctl --user restart openshell-gateway ;; + *) printf 'no supported service manager\n' ;; + esac + fi +} +cd "$project" +case "$action" in + prepare) + if ! $print_only; then + : "${EGRESS_GATE_HOST:?Set the service hostname or IPv4 address in .env}" + : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" + if [[ ! -f $example/models.json ]]; then + echo 'Create models.json from models.json.example and configure your model first.' >&2 + exit 1 + fi + fi + run uv sync --frozen + if $print_only; then + printf '%q ' "${openshell[@]}" gateway list --output json + printf '| ' + run uv run --frozen python "$example/prepare.py" --state "$state" --host "$service_host" --gateway "$gateway" --model "${PI_MODEL:-}" + else + "${openshell[@]}" gateway list --output json | uv run --frozen python "$example/prepare.py" --state "$state" --host "$service_host" --gateway "$gateway" --model "${PI_MODEL:-}" + fi + run docker build --tag pi-admission:local "$state/image" + ;; + serve) + run uv run --frozen egress-gate serve --listen 0.0.0.0:50051 --admission-config "$state/admission.json" + ;; + registration) + run cat "$state/middleware.toml" + ;; + register|unregister) + if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}"; fi + registration "$action" + ;; + setup) + if ! $print_only; then + : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" + : "${PI_MODEL_API_KEY:?Set PI_MODEL_API_KEY in the example .env}" + export PI_MODEL_API_KEY + EGRESS_ADMISSION_TOKEN=$(uv run --frozen python -c 'import json,sys; print(json.load(open(sys.argv[1]))["bearer_token"])' "$state/admission.json") + export EGRESS_ADMISSION_TOKEN + fi + run "${openshell[@]}" gateway info + for provider in model admission; do + run "${openshell[@]}" provider profile import --file "$state/$provider-provider.yaml" + variable=PI_MODEL_API_KEY + [[ $provider != admission ]] || variable=EGRESS_ADMISSION_TOKEN + run "${openshell[@]}" provider create --name "pi-admission-$provider" --type "pi-admission-$provider" --credential "$variable" + done + run "${openshell[@]}" sandbox create --name pi-admission --from pi-admission:local --policy "$state/policy.yaml" --provider pi-admission-model --provider pi-admission-admission --detach -- /bin/sleep infinity + if $print_only; then + printf '%q ' "${openshell[@]}" sandbox get pi-admission --output json + printf '| uv run --frozen python %q --state %q\n' "$example/bind-sandbox.py" "$state" + else + "${openshell[@]}" sandbox get pi-admission --output json | uv run --frozen python "$example/bind-sandbox.py" --state "$state" + fi + ;; + launch) + if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" "${EGRESS_GATE_HOST:?Set the service host in .env}"; fi + run "${openshell[@]}" sandbox exec --tty --name pi-admission -- "${pi_env[@]}" /usr/local/bin/node --disable-warning=UNDICI-EHPA /app/dist/src/cli.js --admission "https://$service_host:5443/v1/admission" + ;; + verify) + if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" "${EGRESS_GATE_HOST:?Set the service host in .env}"; fi + run "${openshell[@]}" sandbox exec --no-tty --name pi-admission -- "${pi_env[@]}" /usr/local/bin/node --disable-warning=UNDICI-EHPA /app/dist/src/verify.js --admission "https://$service_host:5443/v1/admission" + ;; + cleanup) + if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}"; fi + delete_if_present sandbox "${openshell[@]}" sandbox delete pi-admission + for provider in model admission; do + delete_if_present provider "${openshell[@]}" provider delete "pi-admission-$provider" + delete_if_present 'provider profile' "${openshell[@]}" provider profile delete "pi-admission-$provider" + done + run uv run --frozen python -c 'import pathlib,sys; pathlib.Path(sys.argv[1]).unlink(missing_ok=True)' "$state/sandbox-id" + registration unregister + printf 'Sandbox and its sessions removed. Stop serve with Ctrl-C.\n' + printf 'Host configuration remains in %s; the local Docker image is retained.\n' "$state" + ;; + help) + printf 'Usage: ./demo.sh [--print] ACTION\n\n' + printf ' prepare Generate service TLS/config; build the Pi image\n' + printf ' serve Run Egress Gate (keep this terminal open)\n' + printf ' register Add middleware to the local gateway and restart it\n' + printf ' unregister Remove that registration and restart the gateway\n' + printf ' registration Show the TOML entry (manual deployments only; does not register)\n' + printf ' setup Create providers and sandbox; bind admission identity\n' + printf ' launch Start a new interactive Pi-powered session\n' + printf ' verify Run real allow/deny/redact, tools, skill, compaction and bypass checks\n' + printf ' cleanup Delete sandbox/providers/sessions; unregister middleware\n' + printf '\n--print shows commands without executing .env, requiring secrets, or changing state.\n' + ;; + *) echo "Unknown action. Run ./demo.sh help." >&2; exit 2 ;; +esac diff --git a/projects/egress-gate/examples/pi-attested-admission/gateway-registration.py b/projects/egress-gate/examples/pi-attested-admission/gateway-registration.py new file mode 100644 index 00000000..1a37bc96 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/gateway-registration.py @@ -0,0 +1,192 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Register this demo with a local, installer-managed OpenShell gateway.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import subprocess +import sys +import tempfile +import time +import tomllib +from pathlib import Path +from urllib.parse import urlsplit + +from egress_gate.gateway_config import ( + default_gateway_config_path, + list_gateway_registrations, + remove_gateway_config, +) + + +def configure(action: str, state: Path, gateway: str) -> None: + record = state / "gateway-registration.json" + if action == "unregister" and not record.exists(): + print("No demo-managed gateway registration to remove.") + return + gateways = json.loads(_output("openshell", "gateway", "list", "--output", "json")) + selected = next((item for item in gateways if item["name"] == gateway), None) + endpoint = urlsplit(selected["endpoint"] if selected else "") + if ( + endpoint.scheme != "https" + or endpoint.hostname not in {"localhost", "127.0.0.1", "::1"} + or endpoint.port != 17670 + ): + raise ValueError( + "Select the local installer-managed gateway on HTTPS port 17670." + ) + + config, restart = _local_service() + registration = {"gateway": gateway, "config": str(config)} + saved = json.loads(record.read_text()) if record.exists() else None + if saved is not None and any(saved.get(k) != v for k, v in registration.items()): + raise ValueError( + "Gateway/config changed; restore the previous selection to clean up first." + ) + + if action == "register": + fragment = (state / "middleware.toml").read_text() + desired = tomllib.loads(fragment)["openshell"]["supervisor"]["middleware"][0] + original = ( + config.read_text() if config.exists() else "[openshell]\nversion = 1\n" + ) + # The shared reader validates the middleware table before we edit anything. + matches = [ + r for r in list_gateway_registrations(config) if r.name == "pi-egress" + ] + if matches: + entries = tomllib.loads(original)["openshell"]["supervisor"]["middleware"] + existing = [entry for entry in entries if entry.get("name") == "pi-egress"] + if existing != [desired] or saved is None or saved.get("entry") != desired: + raise ValueError( + "pi-egress is already registered; refusing to overwrite it." + ) + else: + updated = original.rstrip() + "\n\n" + fragment + tomllib.loads(updated) + # Remember ownership so cleanup also works after a failed restart. + record.write_text(json.dumps({**registration, "entry": desired}) + "\n") + config.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", dir=config.parent, delete=False + ) as temporary: + try: + temporary.write(updated) + temporary.close() + if config.exists(): + os.chmod(temporary.name, config.stat().st_mode & 0o777) + Path(temporary.name).replace(config) + finally: + Path(temporary.name).unlink(missing_ok=True) + print(f"Registered pi-egress in {config}", flush=True) + else: + entries = ( + ( + tomllib.loads(config.read_text()) + .get("openshell", {}) + .get("supervisor", {}) + .get("middleware", []) + ) + if config.exists() + else [] + ) + existing = [entry for entry in entries if entry.get("name") == "pi-egress"] + if existing and (saved is None or existing != [saved.get("entry")]): + raise ValueError("pi-egress registration changed; refusing to remove it.") + remove_gateway_config(config, middleware_name="pi-egress") + print(f"Removed pi-egress from {config}", flush=True) + + # Restart even on a retry: the previous write may have succeeded but reload failed. + print( + f"+ {shlex.join(restart)} (briefly interrupts this gateway)", + flush=True, + ) + subprocess.run(restart, check=True) + _wait_for_gateway(gateway) + if action == "unregister": + record.unlink() + + +def _output(*command: str) -> str: + return subprocess.check_output(command, text=True, timeout=10) + + +def _local_service() -> tuple[Path, list[str]]: + config = default_gateway_config_path() + service_env = config.with_name("gateway.env") + if sys.platform == "darwin": + # Match the Homebrew wrapper: user config, then prefix config. + prefix = Path(_output("brew", "--prefix").strip()) / "var/openshell" + if not service_env.is_file(): + service_env = prefix / "gateway.env" + if not config.is_file(): + config = prefix / "gateway.toml" + config = config.resolve(strict=True) + restart = ["brew", "services", "restart", "openshell"] + elif sys.platform == "linux": + # DEB/RPM installations use defaults until a user config is created. + if ( + _output( + "systemctl", + "--user", + "show", + "openshell-gateway", + "--property=LoadState", + "--value", + ).strip() + != "loaded" + ): + raise ValueError("No installer-managed OpenShell user service found.") + restart = ["systemctl", "--user", "restart", "openshell-gateway"] + else: + raise ValueError("No supported local gateway service manager found.") + # Custom service environments are operator-managed, not inferred from our shell. + if os.environ.get("OPENSHELL_GATEWAY_CONFIG") or ( + service_env.is_file() + and any( + "OPENSHELL_GATEWAY_CONFIG" in line + for line in service_env.read_text().splitlines() + if line.strip() and not line.lstrip().startswith("#") + ) + ): + raise ValueError( + "Custom gateway config override: use operator-managed registration." + ) + return config.resolve(), restart + + +def _wait_for_gateway(gateway: str) -> None: + command = ["openshell", "--gateway", gateway, "gateway", "info", "--output", "json"] + print(f"Waiting for healthy gateway: {shlex.join(command)}", flush=True) + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=5) + if ( + result.returncode == 0 + and json.loads(result.stdout)["status"] == "healthy" + ): + return + except subprocess.TimeoutExpired: + pass + time.sleep(1) + raise ValueError( + "Gateway did not become healthy. Check the gateway service logs, then retry." + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("register", "unregister")) + parser.add_argument("--state", type=Path, required=True) + parser.add_argument("--gateway", required=True) + args = parser.parse_args() + try: + configure(args.action, args.state, args.gateway) + except (OSError, ValueError, subprocess.SubprocessError) as error: + parser.exit(1, f"{error}\n") diff --git a/projects/egress-gate/examples/pi-attested-admission/models.json.example b/projects/egress-gate/examples/pi-attested-admission/models.json.example new file mode 100644 index 00000000..4c0e6335 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/models.json.example @@ -0,0 +1,40 @@ +{ + "providers": { + "openrouter": { + "baseUrl": "https://openrouter.ai/api/v1", + "api": "openai-completions", + "compat": { + "maxTokensField": "max_tokens", + "supportsDeveloperRole": false, + "supportsReasoningEffort": true + }, + "models": [ + { + "id": "z-ai/glm-5.3-flash", + "name": "GLM-5.3-Flash (OpenRouter)", + "reasoning": true, + "thinkingLevelMap": { + "off": null, + "minimal": null, + "low": "low", + "medium": null, + "high": "high", + "xhigh": null, + "max": "max" + }, + "input": [ + "text" + ], + "contextWindow": 1048576, + "maxTokens": 32768, + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + } + } + ] + } + } +} diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/package-lock.json b/projects/egress-gate/examples/pi-attested-admission/pi-harness/package-lock.json new file mode 100644 index 00000000..286ecaee --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/package-lock.json @@ -0,0 +1,2218 @@ +{ + "name": "pi-admission-example", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-admission-example", + "dependencies": { + "@earendil-works/pi-agent-core": "0.85.1", + "@earendil-works/pi-ai": "0.85.1", + "@earendil-works/pi-coding-agent": "0.85.1", + "undici": "8.9.0" + }, + "devDependencies": { + "@types/node": "22.19.19", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.123.0.tgz", + "integrity": "sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.82", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.82.tgz", + "integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.34.tgz", + "integrity": "sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.29.tgz", + "integrity": "sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.52.tgz", + "integrity": "sha512-vsPPM+nMbKJlUCFU+eoGZbdxdxDIAX9LbpjSXaR5Ufpmqgp8TdYQnoExhLu4T3umW/JIIPny1ydbhWidZZYokQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.10.tgz", + "integrity": "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/chord": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.85.1.tgz", + "integrity": "sha512-VDlkEC3dhCzQ5fcyH1OhG19dq+6jCn+rqc/iXFivwDYGR5anwo2RCiXij9PpHhqNR5GuhhE+Er69Zi1Sn4eY6w==", + "license": "MIT", + "dependencies": { + "esbuild": "0.28.1" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.85.1.tgz", + "integrity": "sha512-hIXIP3eAWueAYiAl8aMvWCvvZ8Q5gT3Dip5bE5uJyIGh4+YlWRjtMLI4BaeoXoSs93zndjue61u1B/vhefLnuA==", + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^0.85.1", + "@earendil-works/pi-ai": "^0.85.1", + "@earendil-works/pi-telemetry": "^0.85.1", + "diff": "8.0.4", + "ignore": "7.0.5", + "typebox": "1.3.7", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.85.1.tgz", + "integrity": "sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.123.0", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.85.1", + "@google/genai": "1.52.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.40.0", + "partial-json": "0.1.7", + "typebox": "1.3.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.85.1.tgz", + "integrity": "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==", + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^0.85.1", + "@earendil-works/pi-agent-core": "^0.85.1", + "@earendil-works/pi-ai": "^0.85.1", + "@earendil-works/pi-tui": "^0.85.1", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "grok-mermaid": "0.2.2", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.3.7", + "undici": "8.9.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/bundle/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.85.1.tgz", + "integrity": "sha512-Bg/YN6kA7Swja/NQxka8xFdecb4E/auIEGF2G5A25EaQXhRnPj300/7/KpgsDDMYUzHTDAv4RyUxaQPJKW81Rw==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.85.1.tgz", + "integrity": "sha512-OIzw9efInmO4WOBnD4TxcTdBjmzvYJpzslkgoUro946nEGoYWg5rwv1p4fDt3/JvMx9QybryUCUwlm7j8Dreig==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz", + "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "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/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/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/grok-mermaid": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.2.tgz", + "integrity": "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "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/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "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/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "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": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "license": "MIT" + }, + "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/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "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/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/package.json b/projects/egress-gate/examples/pi-attested-admission/pi-harness/package.json new file mode 100644 index 00000000..a1fbfd80 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/package.json @@ -0,0 +1,24 @@ +{ + "name": "pi-admission-example", + "private": true, + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "scripts": { + "build": "tsc", + "check": "tsc --noEmit", + "test": "node dist/test/session.test.js && node dist/test/admission.test.js", + "start": "node dist/src/cli.js" + }, + "dependencies": { + "@earendil-works/pi-agent-core": "0.85.1", + "@earendil-works/pi-ai": "0.85.1", + "@earendil-works/pi-coding-agent": "0.85.1", + "undici": "8.9.0" + }, + "devDependencies": { + "@types/node": "22.19.19", + "typescript": "5.9.3" + } +} diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/admission.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/admission.ts new file mode 100644 index 00000000..207ee38f --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/admission.ts @@ -0,0 +1,297 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; +import type { + Context, + Message, + TextContent, +} from "@earendil-works/pi-ai"; + +export const RECEIPT_HEADER = "x-egress-admission"; +export const MAX_ADMISSION_BYTES = 4 * 1024 * 1024; + +export type TextOrigin = "user" | "system" | "compaction_summary"; +export type AdmissionKind = + | "user_message" + | "system_context" + | "compaction_summary" + | "assistant_message" + | "tool_result" + | "provider_context"; +export type AdmissionResponse = { + decision: "allow" | "replace" | "deny"; + replacement: Record | null; + receipt: string | null; +}; +export type Evaluate = ( + kind: AdmissionKind, + body: Record, + signal?: AbortSignal, +) => Promise; + +export class AdmissionError extends Error { + constructor( + readonly kind: "denied" | "unavailable" | "unsupported" | "invalid", + ) { + super( + { + denied: + "Admission denied this content; the candidate was not added to history.", + unavailable: + "Admission is unavailable; no unchecked content will be added.", + unsupported: + "This content is outside the example’s supported Chat Completions format.", + invalid: + "Admission returned an inconsistent result; the operation was stopped.", + }[kind], + ); + } +} + +export function createHttpEvaluator( + url: string, + credential: string, + sessionId: string, +): Evaluate { + if (new URL(url).protocol !== "https:") + throw new Error("Admission requires HTTPS."); + return async (kind, body, signal) => { + const encoded = JSON.stringify({ + kind, + body, + session_id: sessionId, + submission_id: randomUUID(), + }); + if (Buffer.byteLength(encoded) > MAX_ADMISSION_BYTES) + throw new AdmissionError("unsupported"); + try { + const response = await fetch(url, { + method: "POST", + headers: { + authorization: `Bearer ${credential}`, + "content-type": "application/json", + }, + body: encoded, + signal: AbortSignal.any([ + AbortSignal.timeout(30_000), + ...(signal ? [signal] : []), + ]), + }); + if (!response.ok) throw new AdmissionError("unavailable"); + const encodedResult = await response.text(); + if (Buffer.byteLength(encodedResult) > MAX_ADMISSION_BYTES + 16_384) + throw new AdmissionError("invalid"); + const result: unknown = JSON.parse(encodedResult); + if ( + !isRecord(result) || + !["allow", "replace", "deny"].includes(String(result.decision)) + ) + throw new AdmissionError("invalid"); + if (result.decision === "deny") + return { decision: "deny", replacement: null, receipt: null }; + if (result.decision === "allow" && result.replacement !== null) + throw new AdmissionError("invalid"); + if (result.decision === "replace" && !isRecord(result.replacement)) + throw new AdmissionError("invalid"); + if (kind === "provider_context") { + if ( + typeof result.receipt !== "string" || + !/^[A-Za-z0-9_-]+={0,2}$/.test(result.receipt) || + result.receipt.length > 11_000 + ) + throw new AdmissionError("invalid"); + } else if (result.receipt !== null) throw new AdmissionError("invalid"); + return { + decision: result.decision as "allow" | "replace", + replacement: result.replacement as Record | null, + receipt: result.receipt as string | null, + }; + } catch (error) { + if (error instanceof AdmissionError) throw error; + throw new AdmissionError("unavailable"); + } + }; +} + +export class Admission { + constructor(private readonly evaluate: Evaluate) {} + + async text( + origin: TextOrigin, + text: string, + signal?: AbortSignal, + ): Promise { + const kind = { + user: "user_message", + system: "system_context", + compaction_summary: "compaction_summary", + } as const; + const envelope = { + schema_version: "openshell.pi-message.v1", + origin, + text, + }; + const admitted = await this.apply(kind[origin], envelope, signal); + if ( + admitted.origin !== origin || + admitted.schema_version !== envelope.schema_version || + typeof admitted.text !== "string" + ) + throw new AdmissionError("invalid"); + return admitted.text; + } + + async message(message: Message, signal?: AbortSignal): Promise { + if (message.role === "user") { + const original = textOnly(message.content); + const approved = await this.text("user", original, signal); + if (approved === original) return message; + if (typeof message.content === "string") + return { ...message, content: approved }; + if (message.content.length !== 1 || message.content[0].type !== "text" || + message.content[0].textSignature) + throw new AdmissionError("invalid"); + return { ...message, content: [{ ...message.content[0], text: approved }] }; + } + if (message.role === "assistant") { + if (message.content.some((block) => + block.type !== "text" && block.type !== "toolCall" && block.type !== "thinking")) + throw new AdmissionError("unsupported"); + const texts = message.content.filter((block) => block.type === "text"); + const thinking = message.content.filter((block) => block.type === "thinking"); + const calls = message.content.filter((block) => block.type === "toolCall").map((call) => ({ + id: call.id, name: call.name, arguments: call.arguments, + thought_signature: call.thoughtSignature ?? null, + })); + const envelope = { + schema_version: "openshell.pi-assistant-message.v1", + text: texts.map((block) => block.text).join("\n"), + tool_calls: calls, + thinking: thinking.map((block) => ({ + text: block.thinking, signature: block.thinkingSignature ?? null, + })), + }; + const admitted = await this.apply("assistant_message", envelope, signal); + // Preserve the complete native message on allow, including block order, + // signatures, usage and provider metadata. + if (admitted === envelope) return message; + if (typeof admitted.text !== "string" || + !isDeepStrictEqual(admitted.tool_calls, calls) || + !Array.isArray(admitted.thinking) || admitted.thinking.length !== thinking.length) + throw new AdmissionError("invalid"); + const changedText = admitted.text !== envelope.text; + // A joined text projection cannot safely identify edits across multiple blocks. + if (changedText && (texts.length !== 1 || texts[0].textSignature)) + throw new AdmissionError("invalid"); + const replacements = admitted.thinking.map((value: unknown, index: number) => { + const original = envelope.thinking[index]; + if (!isRecord(value) || typeof value.text !== "string" || + value.signature !== original.signature || + (original.signature !== null && + !["reasoning", "reasoning_content", "reasoning_text"].includes(original.signature) && + value.text !== original.text)) + throw new AdmissionError("invalid"); + return value.text; + }); + let index = 0; + return { + ...message, + content: message.content.map((block) => { + if (block.type === "text" && changedText) + return { ...block, text: admitted.text as string }; + if (block.type === "thinking") + return { ...block, thinking: replacements[index++] }; + return block; + }), + }; + } + // Keep text block boundaries and metadata; images remain outside this POC. + textOnly(message.content); + const envelope = { + schema_version: "openshell.pi-tool-result.v1", + tool_call_id: message.toolCallId, + tool_name: message.toolName, + content: message.content.map((block) => ({ type: "text", text: (block as TextContent).text })), + is_error: message.isError, + }; + const admitted = await this.apply("tool_result", envelope, signal); + if (admitted === envelope) return message; + if (admitted.tool_call_id !== message.toolCallId || + admitted.tool_name !== message.toolName || admitted.is_error !== message.isError || + !Array.isArray(admitted.content) || admitted.content.length !== message.content.length) + throw new AdmissionError("invalid"); + const content = admitted.content.map((value: unknown, index: number): TextContent => { + const original = message.content[index] as TextContent; + if (!isRecord(value) || value.type !== "text" || typeof value.text !== "string" || + (original.textSignature && value.text !== original.text)) + throw new AdmissionError("invalid"); + return { ...original, text: value.text }; + }); + return { ...message, content }; + } + + async receipt(context: Context, signal?: AbortSignal): Promise { + const entries = context.messages.flatMap((message) => { + if (message.role === "user") + return [{ role: "user", text: textOnly(message.content) }]; + if (message.role === "toolResult") + return [ + { + role: "tool", + tool_call_id: message.toolCallId.split("|", 1)[0], + text: textOnly(message.content) || "(no tool output)", + }, + ]; + return []; + }); + const result = await this.evaluate( + "provider_context", + { schema_version: "openshell.pi-provider-context.v1", entries }, + signal, + ); + if (result.decision === "deny") throw new AdmissionError("denied"); + // A send-only replacement would leave saved history inconsistent. Fix the + // earlier admission boundary instead of silently diverging at egress. + if (result.decision !== "allow" || !result.receipt) + throw new AdmissionError("invalid"); + return result.receipt; + } + + private async apply( + kind: AdmissionKind, + body: Record, + signal?: AbortSignal, + ): Promise> { + const result = await this.evaluate(kind, body, signal); + if (signal?.aborted) throw new AdmissionError("unavailable"); + if (result.decision === "deny") throw new AdmissionError("denied"); + if (result.decision === "replace") { + if ( + !result.replacement || + result.replacement.schema_version !== body.schema_version + ) + throw new AdmissionError("invalid"); + return result.replacement; + } + return body; + } +} + +export function textOnly( + content: string | readonly { type: string; text?: string }[], +): string { + if (typeof content === "string") return content; + if ( + content.some( + (block) => block.type !== "text" || typeof block.text !== "string", + ) + ) + throw new AdmissionError("unsupported"); + return content.map((block) => block.text).join("\n"); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/agent.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/agent.ts new file mode 100644 index 00000000..f037c2e2 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/agent.ts @@ -0,0 +1,421 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + Agent, + type AgentEvent, + type AgentMessage, + type AgentContext, + type StreamFn, +} from "@earendil-works/pi-agent-core"; +import { + clampThinkingLevel, + isContextOverflow, + validateToolArguments, + type AssistantMessage, + type ImageContent, + type Message, + type Model, + type ToolResultMessage, + type Usage, +} from "@earendil-works/pi-ai"; +import { convertToLlm } from "@earendil-works/pi-coding-agent"; +import { Admission, AdmissionError } from "./admission.js"; + +export class ContextOverflowError extends Error {} + +/** Own the execution loop so even pending content never enters Pi's reducer. + * AgentSession alone persists the approved message_end events. + */ +export class AdmissionAgent extends Agent { + private readonly live; + private systemPromptCandidate = ""; + private approvedSystemPrompt = ""; + private readonly subscribers = new Set< + (event: AgentEvent, signal: AbortSignal) => Promise | void + >(); + private readonly steering: AgentMessage[] = []; + private readonly followUps: AgentMessage[] = []; + private controller?: AbortController; + private settled: Promise = Promise.resolve(); + stopped = false; + + constructor( + model: Model<"openai-completions">, + streamFn: StreamFn, + private readonly admission: Admission, + ) { + // Match Pi's default thinking level; the session's native controls can change it. + super({ + initialState: { model, thinkingLevel: clampThinkingLevel(model, "medium") }, + streamFn, + }); + // Pi's base lifecycle fields are readonly. This engine owns its own public + // state and lifecycle; it never invokes the base execution/state reducer. + const owner = this; + this.live = { + ...super.state, + // Pi rebuilds this field synchronously. Stage those writes as candidates; + // public state continues to expose only the last approved system prompt. + get systemPrompt(): string { + return owner.approvedSystemPrompt; + }, + set systemPrompt(value: string) { + owner.systemPromptCandidate = value; + }, + pendingToolCalls: new Set(), + }; + } + + async approveSystemPrompt(signal?: AbortSignal): Promise { + const approved = await this.admission.text( + "system", + this.systemPromptCandidate, + signal, + ); + signal?.throwIfAborted(); + this.approvedSystemPrompt = approved; + return approved; + } + + override get state() { + return this.live; + } + override get signal() { + return this.controller?.signal; + } + override subscribe( + listener: (event: AgentEvent, signal: AbortSignal) => Promise | void, + ) { + this.subscribers.add(listener); + return () => { + this.subscribers.delete(listener); + }; + } + override abort() { + this.controller?.abort(); + } + override waitForIdle() { + return this.settled; + } + override steer(message: AgentMessage) { + this.steering.push(message); + } + override followUp(message: AgentMessage) { + this.followUps.push(message); + } + override clearSteeringQueue() { + this.steering.length = 0; + } + override clearFollowUpQueue() { + this.followUps.length = 0; + } + override clearAllQueues() { + this.clearSteeringQueue(); + this.clearFollowUpQueue(); + } + override hasQueuedMessages() { + return this.steering.length + this.followUps.length > 0; + } + override reset() { + if (this.live.isStreaming) + throw new Error("Cancel the current operation first."); + this.live.messages = []; + this.live.errorMessage = undefined; + this.stopped = false; + this.clearAllQueues(); + } + override prompt( + input: string | AgentMessage | AgentMessage[], + images?: ImageContent[], + ): Promise { + if (images?.length) + return Promise.reject(new AdmissionError("unsupported")); + const messages: AgentMessage[] = + typeof input === "string" + ? [{ role: "user", content: input, timestamp: Date.now() }] + : Array.isArray(input) + ? input + : [input]; + return this.run(messages); + } + override continue(): Promise { + const last = this.live.messages.at(-1); + if ( + !this.hasQueuedMessages() && + last?.role !== "user" && + last?.role !== "toolResult" + ) + return Promise.reject( + new Error("There is no unfinished turn to continue."), + ); + return this.run([]); + } + + private async run(candidates: AgentMessage[]): Promise { + if (this.live.isStreaming || this.stopped) + throw new Error("Session is busy or stopped; use /new if stopped."); + this.controller = new AbortController(); + this.live.isStreaming = true; + this.live.errorMessage = undefined; + let settle!: () => void; + this.settled = new Promise((resolve) => { + settle = resolve; + }); + const published: AgentMessage[] = []; + try { + await this.emit({ type: "agent_start" }); + await this.admitBatch(candidates, published); + const steered = await this.drain( + this.steering, + this.steeringMode, + published, + ); + if (!candidates.length && !steered) + await this.drain(this.followUps, this.followUpMode, published); + for (;;) { + this.signal!.throwIfAborted(); + await this.emit({ type: "turn_start" }); + // AgentSession rebuilds system context when tools/settings change. + // Approve that snapshot before every provider call. + const systemPrompt = await this.approveSystemPrompt(this.signal); + const response = await ( + await this.streamFunction( + this.live.model, + { + systemPrompt, + messages: convertToLlm(this.live.messages), + tools: this.live.tools, + }, + { + signal: this.signal, + sessionId: this.sessionId, + reasoning: this.live.thinkingLevel === "off" ? undefined : this.live.thinkingLevel, + }, + ) + ).result(); + this.signal!.throwIfAborted(); + if (isContextOverflow(response, this.live.model.contextWindow)) + throw new ContextOverflowError("Context is too large."); + if ( + response.stopReason === "error" || + response.stopReason === "aborted" + ) + throw new Error( + "Model request failed or was cancelled; no response was saved.", + ); + const assistant = (await this.admit(response)) as AssistantMessage; + await this.publish(assistant, published); + const calls = assistant.content.filter( + (block) => block.type === "toolCall", + ); + const toolResults: ToolResultMessage[] = []; + for (let index = 0; index < calls.length; index++) { + const call = calls[index]; + try { + if (assistant.stopReason === "length") + throw new Error("Incomplete tool call."); + this.signal!.throwIfAborted(); + const tool = this.live.tools.find( + (tool) => tool.name === call.name, + ); + let args: unknown = call.arguments; + let result; + let isError = false; + this.live.pendingToolCalls.add(call.id); + await this.emit({ + type: "tool_execution_start", + toolCallId: call.id, + toolName: call.name, + args, + }); + try { + if (!tool) throw new Error("Requested tool is not available."); + // Pi's edit tool normalizes common model argument shapes in place. + // Keep that preparation separate from the already-approved message. + const prepared = structuredClone(call); + prepared.arguments = (tool.prepareArguments + ? tool.prepareArguments(prepared.arguments) + : prepared.arguments) as typeof prepared.arguments; + args = validateToolArguments(tool, prepared); + // No onUpdate callback: partial tool output is not approved yet. + result = await tool.execute(call.id, args, this.signal); + } catch (error) { + isError = true; + result = { + content: [ + { + type: "text" as const, + text: + error instanceof Error + ? error.message + : "Tool execution failed.", + }, + ], + details: undefined, + }; + } + const approved = (await this.admit({ + role: "toolResult", + toolCallId: call.id, + toolName: call.name, + content: result.content, + isError: isError, + timestamp: Date.now(), + })) as ToolResultMessage; + await this.publishTool(approved, published); + toolResults.push(approved); + } catch (error) { + // Close outstanding pairs with separately admitted, content-free + // failures. If admission is unavailable, require a new session. + try { + for (const pending of calls.slice(index)) { + const approved = (await this.admit({ + role: "toolResult", + toolCallId: pending.id, + toolName: pending.name, + content: [ + { + type: "text", + text: "Tool result unavailable; this turn was stopped.", + }, + ], + isError: true, + timestamp: Date.now(), + })) as ToolResultMessage; + await this.publishTool(approved, published); + } + } catch { + this.stopped = true; + } + throw error; + } + } + await this.emit({ type: "turn_end", message: assistant, toolResults }); + const steered = await this.drain( + this.steering, + this.steeringMode, + published, + ); + const followedUp = + !calls.length && + !steered && + (await this.drain(this.followUps, this.followUpMode, published)); + if (!calls.length && !steered && !followedUp) break; + // Native automatic compaction between tool turns uses the same + // session_before_compact admission hook as manual compaction. + await this.prepareNextTurnWithContext?.( + { + message: assistant, + toolResults, + context: this.context(), + newMessages: published, + }, + this.signal, + ); + } + } catch (error) { + this.clearAllQueues(); + // Never turn an unchecked exception or partial provider response into a + // persisted assistant error message. + this.live.errorMessage = + error instanceof AdmissionError + ? error.message + : "Operation stopped; no unchecked content was saved."; + if ( + error instanceof AdmissionError || + error instanceof ContextOverflowError + ) + throw error; + throw new Error(this.live.errorMessage); + } finally { + try { + await this.emit({ type: "agent_end", messages: published }); + } finally { + this.live.pendingToolCalls.clear(); + this.live.isStreaming = false; + this.controller = undefined; + settle(); + } + } + } + + private context(): AgentContext { + return { + systemPrompt: this.live.systemPrompt, + messages: this.live.messages.slice(), + tools: this.live.tools, + }; + } + private async admit(candidate: AgentMessage): Promise { + if ( + candidate.role !== "user" && + candidate.role !== "assistant" && + candidate.role !== "toolResult" + ) + throw new AdmissionError("unsupported"); + return this.admission.message(candidate, this.signal); + } + private async admitBatch( + candidates: AgentMessage[], + published: AgentMessage[], + ) { + const approved = []; + for (const candidate of candidates) + approved.push(await this.admit(candidate)); + this.signal!.throwIfAborted(); + for (const message of approved) await this.publish(message, published); + } + private async drain( + queue: AgentMessage[], + mode: string, + published: AgentMessage[], + ): Promise { + if (!queue.length) return false; + const candidates = queue.splice(0, mode === "all" ? queue.length : 1); + await this.admitBatch(candidates, published); + return true; + } + private async publish(message: Message, published: AgentMessage[]) { + this.live.messages = [...this.live.messages, message]; + published.push(message); + await this.emit({ type: "message_start", message }); + await this.emit({ type: "message_end", message }); + } + private async publishTool( + message: ToolResultMessage, + published: AgentMessage[], + ) { + this.live.pendingToolCalls.delete(message.toolCallId); + // Drop unchecked details (including edit diffs) and usage metadata. + await this.emit({ + type: "tool_execution_end", + toolCallId: message.toolCallId, + toolName: message.toolName, + result: { content: message.content }, + isError: message.isError, + }); + await this.publish(message, published); + } + private async emit(event: AgentEvent) { + for (const subscriber of this.subscribers) + await subscriber(event, this.signal!); + } +} + +export function retainedUsage(usage: Usage): Usage { + return { + input: usage.input, + output: usage.output, + cacheRead: usage.cacheRead, + cacheWrite: usage.cacheWrite, + totalTokens: usage.totalTokens, + cost: { + input: usage.cost.input, + output: usage.cost.output, + cacheRead: usage.cost.cacheRead, + cacheWrite: usage.cost.cacheWrite, + total: usage.cost.total, + }, + }; +} diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/cli.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/cli.ts new file mode 100644 index 00000000..006d53e7 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/cli.ts @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from "node:crypto"; +import { parseArgs } from "node:util"; +import { InteractiveMode, convertToLlm } from "@earendil-works/pi-coding-agent"; +import { Admission, AdmissionError, createHttpEvaluator } from "./admission.js"; +import { createAdmissionRuntime } from "./session.js"; +import { configureProxy } from "./network.js"; +import { loadSelectedModel } from "./model.js"; + +async function main(): Promise { + configureProxy(); + const { values } = parseArgs({ + options: { + cwd: { type: "string", default: "/sandbox/project" }, + "session-dir": { type: "string", default: "/sandbox/sessions" }, + admission: { type: "string" }, + prompt: { type: "string" }, + }, + }); + const apiKey = process.env.PI_MODEL_API_KEY; + const admissionKey = process.env.EGRESS_ADMISSION_TOKEN; + // Retain endpoint-bound placeholders only in the application, not in tool + // child environments. This is hygiene, not isolation from same-authority code. + delete process.env.PI_MODEL_API_KEY; + delete process.env.EGRESS_ADMISSION_TOKEN; + if (!apiKey || !admissionKey || !values.admission) + throw new Error("Missing provider or admission configuration."); + const model = await loadSelectedModel(); + const runtime = await createAdmissionRuntime({ + cwd: values.cwd, + sessionDir: values["session-dir"], + agentDir: "/app/agent", + model, + apiKey, + admission: new Admission( + createHttpEvaluator(values.admission, admissionKey, randomUUID()), + ), + }); + if (values.prompt !== undefined) { + try { + await runtime.session.bindExtensions({}); + await runtime.session.prompt(values.prompt); + console.log( + JSON.stringify(convertToLlm(runtime.session.messages), null, 2), + ); + } finally { + await runtime.dispose(); + } + return; + } + await new InteractiveMode(runtime).run(); +} + +main().catch((error) => { + console.error( + error instanceof AdmissionError + ? error.message + : "Example failed; check configuration and service availability.", + ); + process.exitCode = 1; +}); diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/model.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/model.ts new file mode 100644 index 00000000..4d942d19 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/model.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + InMemoryCredentialStore, + InMemoryModelsStore, + type Model, +} from "@earendil-works/pi-ai"; +import { ModelRuntime } from "@earendil-works/pi-coding-agent"; + +/** Let Pi resolve its native catalog, without writing into the read-only image. */ +export async function loadSelectedModel( + directory = "/app", +): Promise> { + const { provider, id } = JSON.parse( + await readFile(join(directory, "model-selection.json"), "utf8"), + ) as { provider: string; id: string }; + const runtime = await ModelRuntime.create({ + credentials: new InMemoryCredentialStore(), + modelsStore: new InMemoryModelsStore(), + modelsPath: join(directory, "models.json"), + }); + const error = runtime.getError(); + if (error) throw new Error(error); + const model = runtime.getModel(provider, id); + if (!model || model.api !== "openai-completions") + throw new Error("The prepared model must use openai-completions."); + return model as Model<"openai-completions">; +} diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/network.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/network.ts new file mode 100644 index 00000000..c45e080c --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/network.ts @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici"; + +/** Honor the sandbox's proxy after loading Pi and its HTTP dependencies. */ +export function configureProxy(): void { + setGlobalDispatcher( + new EnvHttpProxyAgent({ proxyTunnel: true, allowH2: false }), + ); +} diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/session.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/session.ts new file mode 100644 index 00000000..78318af4 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/session.ts @@ -0,0 +1,288 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { resolve } from "node:path"; +import type { StreamFn } from "@earendil-works/pi-agent-core"; +import { InMemoryCredentialStore, type Model } from "@earendil-works/pi-ai"; +import { streamSimple } from "@earendil-works/pi-ai/compat"; +import { + AgentSession, + AgentSessionRuntime, + SessionManager, + SettingsManager, + ModelRuntime, + createAgentSessionServices, + convertToLlm, + compact, + type CreateAgentSessionRuntimeFactory, + type PromptOptions, +} from "@earendil-works/pi-coding-agent"; +import { Admission, AdmissionError, RECEIPT_HEADER } from "./admission.js"; +import { + AdmissionAgent, + ContextOverflowError, + retainedUsage, +} from "./agent.js"; +import { projectTools } from "./tools.js"; + +export { projectTools } from "./tools.js"; + +export interface SessionOptions { + cwd: string; + sessionDir: string; + agentDir: string; + model: Model<"openai-completions">; + apiKey: string; + admission: Admission; + /** Deterministic integration tests use Pi's public stream/tool seams. */ + stream?: StreamFn; + compactAtTokens?: number; +} + +/** Native Pi session/persistence, with explicit guards for unsupported writes. */ +export class AdmissionSession extends AgentSession { + static async create(options: SessionOptions): Promise { + const result = await sessionFactory(options)({ + cwd: resolve(options.cwd), + agentDir: resolve(options.agentDir), + sessionManager: SessionManager.create( + resolve(options.cwd), + resolve(options.sessionDir), + ), + }); + await result.session.bindExtensions({}); + return result.session; + } + + get history() { + return structuredClone(convertToLlm(this.messages)); + } + get entries() { + return structuredClone(this.sessionManager.getEntries()); + } + override get sessionFile(): string { + return this.sessionManager.getSessionFile()!; + } + get isStopped() { + return (this.agent as AdmissionAgent).stopped; + } + + override async prompt(text: string, options?: PromptOptions): Promise { + if (this.isStopped) + throw new Error("An unfinished tool batch requires /new."); + try { + await super.prompt(text, options); + } catch (error) { + if (!(error instanceof ContextOverflowError) || !this.autoCompactionEnabled) + throw error; + // The failed provider response was never published. Compact only approved + // history, then retry that unfinished turn once. + await this.compact(); + await this.agent.continue(); + } finally { + if (!this.isStreaming) this.clearQueue(); + } + } + + // These native entry points write outside the agent's message event path. + // Keep them unavailable until each has its own pre-write admission boundary. + override async executeBash(): Promise { + return unsupported("Direct ! commands; ask the model to use the bash tool"); + } + override recordBashResult(): never { + return unsupported("Direct shell results"); + } + override async sendCustomMessage(): Promise { + return unsupported("Custom extension messages"); + } + override async navigateTree(): Promise { + return unsupported("Session branching"); + } + override async reload(): Promise { + return unsupported("Resource reload; use /new"); + } + override async setModel(): Promise { + return unsupported("Model switching"); + } + override async cycleModel(): Promise { + return unsupported("Model switching"); + } + override setSessionName(): never { + return unsupported("Session renaming"); + } +} + +/** Use Pi's real TUI runtime; /new is safe, importing unchecked history is not. */ +export async function createAdmissionRuntime( + options: SessionOptions, +): Promise { + const factory = sessionFactory(options); + const result = await factory({ + cwd: resolve(options.cwd), + agentDir: resolve(options.agentDir), + sessionManager: SessionManager.create( + resolve(options.cwd), + resolve(options.sessionDir), + ), + }); + return new AdmissionRuntime( + result.session, + result.services, + factory, + result.diagnostics, + ); +} + +function sessionFactory(options: SessionOptions) { + if ( + options.model.api !== "openai-completions" || + options.model.input.some((type) => type !== "text") || + new URL(options.model.baseUrl).protocol !== "https:" + ) + throw new AdmissionError("unsupported"); + // Preferences belong to the runtime, not to an individual conversation. + const settingsManager = SettingsManager.inMemory({ + packages: [], + enableInstallTelemetry: false, + compaction: { + enabled: true, + reserveTokens: + options.compactAtTokens === undefined + ? undefined + : options.model.contextWindow - options.compactAtTokens, + }, + retry: { enabled: false }, + }); + const stream: StreamFn = async (model, context, streamOptions) => { + const receipt = await options.admission.receipt( + context, + streamOptions?.signal, + ); + return (options.stream ?? streamSimple)(model, context, { + ...streamOptions, + apiKey: options.apiKey, + headers: { ...streamOptions?.headers, [RECEIPT_HEADER]: receipt }, + }); + }; + return async ({ + cwd, + agentDir, + sessionManager, + sessionStartEvent, + }: Parameters[0]) => { + if (sessionManager.getEntries().length) + return unsupported("Restoring existing history"); + const services = await createAgentSessionServices({ + cwd, + agentDir, + // OpenShell supplies runtime credentials; /app remains read-only. + modelRuntime: await ModelRuntime.create({ + credentials: new InMemoryCredentialStore(), + modelsPath: null, + }), + settingsManager, + resourceLoaderOptions: { + noExtensions: true, + noPromptTemplates: true, + noThemes: true, + extensionFactories: [ + { + name: "admission", + factory: (pi) => { + pi.on("session_before_compact", async (event) => { + // Supplying a summary or explicitly cancelling is mandatory: + // throwing from an extension handler could fall back to Pi's + // unchecked default summarizer. + try { + const summary = await compact( + event.preparation, + options.model, + options.apiKey, + undefined, + event.customInstructions, + event.signal, + session.thinkingLevel, + stream, + undefined, + { enabled: false, maxRetries: 0, baseDelayMs: 0 }, + undefined, + sessionManager.getSessionId(), + ); + const approved = await options.admission.text( + "compaction_summary", + summary.summary, + event.signal, + ); + return { + compaction: { + summary: approved, + firstKeptEntryId: summary.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + ...(summary.usage + ? { usage: retainedUsage(summary.usage) } + : {}), + }, + }; + } catch { + return { cancel: true }; + } + }); + }, + }, + ], + }, + }); + services.modelRuntime.registerProvider(options.model.provider, { + api: options.model.api, + baseUrl: options.model.baseUrl, + models: [options.model], + }); + await services.modelRuntime.setRuntimeApiKey( + options.model.provider, + options.apiKey, + ); + const tools = projectTools(cwd); + const agent = new AdmissionAgent(options.model, stream, options.admission); + agent.sessionId = sessionManager.getSessionId(); + agent.steeringMode = settingsManager.getSteeringMode(); + agent.followUpMode = settingsManager.getFollowUpMode(); + const session = new AdmissionSession({ + agent, + cwd, + sessionManager, + sessionStartEvent, + settingsManager: services.settingsManager, + resourceLoader: services.resourceLoader, + modelRuntime: services.modelRuntime, + baseToolsOverride: Object.fromEntries( + tools.map((tool) => [tool.name, tool]), + ), + initialActiveToolNames: tools.map((tool) => tool.name), + allowedToolNames: tools.map((tool) => tool.name), + }); + // Check project instructions and skill metadata before exposing the session. + await agent.approveSystemPrompt(); + return { + session, + services, + diagnostics: services.diagnostics, + extensionsResult: services.resourceLoader.getExtensions(), + }; + }; +} + +class AdmissionRuntime extends AgentSessionRuntime { + override async switchSession(): Promise { + return unsupported("Resume"); + } + override async importFromJsonl(): Promise { + return unsupported("Import"); + } + override async fork(): Promise { + return unsupported("Fork"); + } +} + +function unsupported(feature: string): never { + throw new Error(`${feature} is not supported by this admission example.`); +} diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/tools.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/tools.ts new file mode 100644 index 00000000..86224b8d --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/tools.ts @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { + createReadTool, + createBashTool, + createEditTool, + createWriteTool, + createGrepTool, + createFindTool, + createLsTool, + createLocalBashOperations, +} from "@earendil-works/pi-coding-agent"; + +/** Keep bash output below Pi's automatic spill-to-file threshold. */ +export function projectTools(cwd: string): AgentTool[] { + const local = createLocalBashOperations(); + const bash = createBashTool(cwd, { + exposeSessionEnvironment: false, + operations: { + async exec(command, directory, options) { + const limit = new AbortController(); + let bytes = 0; + let lines = 0; + const result = await local.exec(command, directory, { + ...options, + signal: AbortSignal.any([ + limit.signal, + ...(options.signal ? [options.signal] : []), + ]), + onData(data) { + bytes += data.length; + lines += data.toString("utf8").split("\n").length - 1; + if (bytes > 16_000 || lines > 1000) limit.abort(); + else if (!limit.signal.aborted) options.onData(data); + }, + }); + if (limit.signal.aborted) + throw new Error( + "Bash output exceeded the example's in-memory limit.", + ); + return result; + }, + }, + }); + return [ + createReadTool(cwd), + bash, + createEditTool(cwd), + createWriteTool(cwd), + createGrepTool(cwd), + createFindTool(cwd), + createLsTool(cwd), + ]; +} diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/verify.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/verify.ts new file mode 100644 index 00000000..48f4542a --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/verify.ts @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { parseArgs } from "node:util"; +import type { Message } from "@earendil-works/pi-ai"; +import { Admission, AdmissionError, createHttpEvaluator } from "./admission.js"; +import { AdmissionSession } from "./session.js"; +import { configureProxy } from "./network.js"; +import { loadSelectedModel } from "./model.js"; + +export function assertProjectRead(history: readonly Message[]): void { + assert.ok( + history.some((message) => + message.role === "toolResult" && + message.toolName === "read" && + !message.isError && + message.content.some((block) => + block.type === "text" && + block.text.includes("This is a real file in the sandbox project.") && + block.text.includes("[REDACTED]"), + ), + ), + "The read tool must successfully return the approved notes.txt content", + ); +} + +/** Real service, upstream runtime, real project tools, and the configured model. */ +async function verify(): Promise { + configureProxy(); + const { values } = parseArgs({ options: { admission: { type: "string" } } }); + const apiKey = process.env.PI_MODEL_API_KEY; + const admissionKey = process.env.EGRESS_ADMISSION_TOKEN; + delete process.env.PI_MODEL_API_KEY; + delete process.env.EGRESS_ADMISSION_TOKEN; + assert.ok( + apiKey && admissionKey && values.admission, + "Missing example configuration", + ); + const model = await loadSelectedModel(); + const makeSession = (compactAtTokens?: number) => + AdmissionSession.create({ + cwd: "/sandbox/project", + sessionDir: "/sandbox/sessions", + agentDir: "/app/agent", + model, + apiKey, + compactAtTokens, + admission: new Admission( + createHttpEvaluator(values.admission!, admissionKey, randomUUID()), + ), + }); + const raw = await fetch(`${model.baseUrl}/chat/completions`, { + method: "POST", + headers: { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + model: model.id, + messages: [{ role: "user", content: "Harmless bypass check" }], + }), + signal: AbortSignal.timeout(30_000), + }); + assert.equal( + raw.status, + 403, + "A request without an approval receipt must be blocked", + ); + console.log("PASS raw provider request without receipt is blocked"); + const session = await makeSession(); + await assert.rejects( + session.prompt("DENY_THIS"), + (error) => error instanceof AdmissionError && error.kind === "denied", + ); + assert.deepEqual(session.entries, []); + console.log( + "PASS denied user input is absent from live history and Pi entries", + ); + await session.prompt("Reply briefly to this harmless text: REDACT_THIS"); + await session.prompt( + "/skill:review Use the read tool to read notes.txt; do not guess its contents.", + ); + assertProjectRead(session.history); + // Force a short-demo compaction without changing the interactive launcher defaults. + session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); + assert.ok( + await session.compact(), + "Manual compaction must summarize an older turn", + ); + const saved = await readFile(session.sessionFile, "utf8"); + for (const snapshot of [ + JSON.stringify(session.history), + JSON.stringify(session.entries), + saved, + ]) { + assert.ok( + !snapshot.includes("DENY_THIS") && !snapshot.includes("REDACT_THIS"), + ); + assert.ok(snapshot.includes("[REDACTED]")); + } + console.log( + "PASS real model, redacted input, rendered skill, tool continuation, manual compaction, and JSONL history", + ); + const automatic = await makeSession(1); + automatic.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); + await automatic.prompt("Reply with a brief greeting."); + await automatic.prompt("Reply with a brief farewell."); + assert.ok(automatic.entries.some((entry) => entry.type === "compaction")); + console.log("PASS automatic compaction through the same admission boundary"); + console.log( + `Saved evidence: ${session.sessionFile}\n${automatic.sessionFile}`, + ); +} + +if (import.meta.main) { + verify().catch(() => { + console.error( + "FAIL end-to-end verification. Check service availability, credentials, model compatibility, and the last PASS line; no checks were skipped.", + ); + process.exitCode = 1; + }); +} diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/admission.test.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/admission.test.ts new file mode 100644 index 00000000..c3043013 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/admission.test.ts @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { AssistantMessage } from "@earendil-works/pi-ai"; +import { + Admission, + AdmissionError, + createHttpEvaluator, + textOnly, +} from "../src/admission.js"; + +test("allow preserves native messages; executable and signed reasoning changes fail closed", async () => { + assert.throws(() => textOnly([{ type: "image" }]), AdmissionError); + const message: AssistantMessage = { + role: "assistant", + api: "openai-completions", + model: "test", + provider: "test", + timestamp: 0, + stopReason: "toolUse", + content: [ + { type: "text", text: "before" }, + { type: "thinking", thinking: "private reasoning", thinkingSignature: "provider-signature" }, + { + type: "toolCall", + id: "call", + name: "bash", + arguments: { command: "original" }, + thoughtSignature: "tool-signature", + }, + { type: "text", text: "after", textSignature: "text-signature" }, + ], + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; + const allowed = new Admission(async () => ({ decision: "allow", replacement: null, receipt: null })); + assert.strictEqual(await allowed.message(message), message); + for (const role of ["user", "toolResult"] as const) { + const candidate = { role, content: [{ type: "text" as const, text: "one" }, { type: "text" as const, text: "two" }], + timestamp: 1, toolCallId: "call", toolName: "read", isError: false }; + assert.strictEqual(await allowed.message(candidate), candidate); + } + const signedChange = new Admission(async (_kind, body) => ({ + decision: "replace", receipt: null, + replacement: { ...body, thinking: [{ text: "changed", signature: "provider-signature" }] }, + })); + await assert.rejects(signedChange.message(message), AdmissionError); + const unsigned = { ...message, content: [{ type: "thinking" as const, thinking: "private reasoning" }] }; + const redactor = new Admission(async (_kind, body) => ({ + decision: "replace", receipt: null, + replacement: { ...body, thinking: [{ text: "approved reasoning", signature: null }] }, + })); + assert.deepEqual((await redactor.message(unsigned)).content, [{ type: "thinking", thinking: "approved reasoning" }]); + const admission = new Admission(async (_kind, body) => ({ + decision: "replace", + replacement: { ...body, tool_calls: [] }, + receipt: null, + })); + await assert.rejects(admission.message(message), AdmissionError); +}); + +test("send-only redaction is rejected, and receipt projection preserves user/tool order", async () => { + const admission = new Admission(async (_kind, body) => { + assert.deepEqual(body.entries, [ + { role: "user", text: "hello" }, + { role: "tool", tool_call_id: "call", text: "(no tool output)" }, + ]); + return { decision: "replace", replacement: body, receipt: "receipt" }; + }); + await assert.rejects( + admission.receipt({ + messages: [ + { role: "user", content: "hello", timestamp: 0 }, + { + role: "toolResult", + toolCallId: "call|provider-suffix", + toolName: "read", + content: [], + isError: false, + timestamp: 0, + }, + ], + }), + AdmissionError, + ); +}); + +test("HTTP client rejects insecure configuration and malformed service output", async () => { + assert.throws(() => + createHttpEvaluator("http://service.test", "placeholder", "session"), + ); + const original = globalThis.fetch; + try { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + decision: "allow", + replacement: { text: "unchecked" }, + receipt: null, + }), + ); + await assert.rejects( + createHttpEvaluator( + "https://service.test", + "placeholder", + "session", + )("user_message", {}), + AdmissionError, + ); + globalThis.fetch = async () => { + throw new Error("RAW_SECRET"); + }; + await assert.rejects( + createHttpEvaluator( + "https://service.test", + "placeholder", + "session", + )("user_message", {}), + (error) => + error instanceof AdmissionError && + !error.message.includes("RAW_SECRET"), + ); + } finally { + globalThis.fetch = original; + } +}); diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/service-integration.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/service-integration.ts new file mode 100644 index 00000000..a3b559b7 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/service-integration.ts @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Invoked by pytest with real local admission/provider endpoints. No stream or +// evaluator is replaced: Pi serializes requests and consumes the provider SSE. +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { InteractiveMode } from "@earendil-works/pi-coding-agent"; +import { + Admission, + AdmissionError, + createHttpEvaluator, +} from "../src/admission.js"; +import { AdmissionSession, createAdmissionRuntime } from "../src/session.js"; +import { loadSelectedModel } from "../src/model.js"; +import { assertProjectRead } from "../src/verify.js"; + +const [endpoint, directory] = process.argv.slice(2); +const model = await loadSelectedModel(join(directory, "image")); +assert.equal(model.id, "z-ai/glm-5.3-flash"); +assert.equal(model.compat?.supportsDeveloperRole, false); +// Only the endpoint changes: exercise the prepared catalog through Pi's parser. +model.baseUrl = `${endpoint}/api/v1`; +const options = (compactAtTokens?: number) => ({ + cwd: join(directory, "image/project"), + sessionDir: join(directory, "sessions"), + agentDir: join(directory, "agent"), + model, + apiKey: "local-test-credential", + admission: new Admission( + createHttpEvaluator( + `${endpoint}/v1/admission`, + "test-admission-credential", + randomUUID(), + ), + ), + compactAtTokens, +}); + +if (process.argv.includes("--tui")) { + const runtime = await createAdmissionRuntime(options()); + assert.equal(runtime.services.modelRuntime.getError(), undefined); + const interactive = new InteractiveMode(runtime, { + initialMessage: "Please repeat REDACT_THIS and café.", + initialMessages: ["/skill:review"], + }); + await interactive.init(); + runtime.session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 5 } }); + await interactive.run(); + process.exit(0); +} + +const create = async (compactAtTokens?: number) => { + const session = await AdmissionSession.create(options(compactAtTokens)); + session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 5 } }); + return session; +}; + +const session = await create(); +if (process.argv.includes("--settings")) { + // The Python provider checks the actual serialized model/cache settings. + await session.prompt("Check model settings"); + process.exit(0); +} +assert.equal(session.modelRuntime.getError(), undefined); +await assert.rejects(session.prompt("DENY_THIS"), AdmissionError); +assert.equal(session.history.length, 0); +assert.equal(session.entries.length, 0); +await session.prompt("Please repeat REDACT_THIS and café."); +await session.prompt("/skill:review"); +assertProjectRead(session.history); +for (const snapshot of [ + JSON.stringify(session.history), + await readFile(session.sessionFile, "utf8"), +]) { + assert.ok(snapshot.includes("[REDACTED]")); + assert.ok( + !snapshot.includes("REDACT_THIS") && !snapshot.includes("DENY_THIS"), + ); +} +assert.ok(await session.compact()); +assert.ok(session.entries.some((entry) => entry.type === "compaction")); +assert.match(JSON.stringify(session.history), /Approved summary/); + +const automatic = await create(1); +await automatic.prompt("Hello"); +await automatic.prompt("One more turn"); +assert.ok(automatic.entries.some((entry) => entry.type === "compaction")); +for (const current of [session, automatic]) { + for (const snapshot of [ + JSON.stringify(current.history), + JSON.stringify(current.entries), + await readFile(current.sessionFile, "utf8"), + ]) { + assert.ok( + !snapshot.includes("REDACT_THIS") && !snapshot.includes("DENY_THIS"), + ); + } +} diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/session.test.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/session.test.ts new file mode 100644 index 00000000..e748789d --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/session.test.ts @@ -0,0 +1,669 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, writeFile, readdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + createAssistantMessageEventStream, + type AssistantMessage, + type Context, + type Model, +} from "@earendil-works/pi-ai"; +import type { StreamFn } from "@earendil-works/pi-agent-core"; +import { + Admission, + type AdmissionKind, + type AdmissionResponse, + type Evaluate, +} from "../src/admission.js"; +import { + AdmissionSession, + createAdmissionRuntime, + projectTools, +} from "../src/session.js"; + +const model: Model<"openai-completions"> = { + id: "test", + name: "Test", + provider: "test", + api: "openai-completions", + baseUrl: "https://provider.test/v1", + reasoning: false, + input: ["text"], + contextWindow: 100000, + maxTokens: 4096, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, +}; +const allow: AdmissionResponse = { + decision: "allow", + replacement: null, + receipt: null, +}; +const deny: AdmissionResponse = { + decision: "deny", + replacement: null, + receipt: null, +}; + +function answer( + text: string, + calls: { + id: string; + name: string; + arguments: Record; + }[] = [], +): AssistantMessage { + return { + role: "assistant", + content: [ + { type: "text", text }, + ...calls.map((call) => ({ type: "toolCall" as const, ...call })), + ], + api: model.api, + model: model.id, + provider: model.provider, + timestamp: Date.now(), + stopReason: calls.length ? "toolUse" : "stop", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; +} + +async function fixture( + evaluate?: Evaluate, + responses: AssistantMessage[] = [answer("Done")], + compactAtTokens?: number, +) { + const cwd = await mkdtemp(join(tmpdir(), "pi-admission-test-")); + await writeFile(join(cwd, "AGENTS.md"), "Use the project tools."); + await mkdir(join(cwd, ".pi/skills/example"), { recursive: true }); + await writeFile( + join(cwd, ".pi/skills/example/SKILL.md"), + "---\nname: example\ndescription: Example skill\n---\nSKILL_CANDIDATE", + ); + const requests: Context[] = []; + const kinds: AdmissionKind[] = []; + const stream: StreamFn = (_model, context, options) => { + assert.ok(options?.headers?.["x-egress-admission"]); + requests.push(structuredClone({ ...context, tools: undefined })); + const response = responses.shift(); + assert.ok(response, "unexpected additional model call"); + const result = createAssistantMessageEventStream(); + result.push({ + type: "done", + reason: response.stopReason === "toolUse" ? "toolUse" : "stop", + message: response, + }); + return result; + }; + const admission = new Admission(async (kind, body, signal) => { + kinds.push(kind); + return ( + (await evaluate?.(kind, body, signal)) ?? + (kind === "provider_context" ? { ...allow, receipt: "receipt" } : allow) + ); + }); + const session = await AdmissionSession.create({ + cwd, + sessionDir: join(cwd, "sessions"), + agentDir: join(cwd, "agent"), + model, + apiKey: "placeholder", + admission, + stream, + compactAtTokens, + }); + session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 5 } }); + return { session, cwd, requests, kinds }; +} + +async function disk(session: AdmissionSession): Promise { + try { + return await readFile(session.sessionFile, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw error; + } +} + +for (const [kind, marker, prompt, responses] of [ + ["user_message", "CANDIDATE", "CANDIDATE", [answer("done")]], + ["user_message", "SKILL_CANDIDATE", "/skill:example", [answer("done")]], + ["assistant_message", "CANDIDATE", "hello", [answer("CANDIDATE")]], + ["assistant_message", "REASONING_CANDIDATE", "hello", [{ + ...answer("done"), + content: [{ type: "thinking", thinking: "REASONING_CANDIDATE" }, { type: "text", text: "done" }] as AssistantMessage["content"], + }]], + [ + "tool_result", + "CANDIDATE", + "read", + [ + answer("", [ + { id: "call", name: "read", arguments: { path: "candidate.txt" } }, + ]), + ], + ], + [ + "tool_result", + "Requested tool is not available", + "read", + [answer("", [{ id: "call", name: "missing", arguments: {} }])], + ], +] as const) { + test(`pending and denied ${kind}: ${marker}`, async () => { + let release!: (result: AdmissionResponse) => void; + let reached!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const seen = new Promise((resolve) => { + reached = resolve; + }); + const { session, cwd } = await fixture( + async (current, body) => { + if (current === kind && JSON.stringify(body).includes(marker)) { + reached(); + return pending; + } + return current === "provider_context" + ? { ...allow, receipt: "receipt" } + : allow; + }, + [...responses], + ); + const events: unknown[] = []; + session.subscribe((event) => { + events.push(structuredClone(event)); + }); + await writeFile(join(cwd, "candidate.txt"), "CANDIDATE"); + const run = session.prompt(prompt); + await seen; + assert.ok(!JSON.stringify(session.entries).includes(marker)); + assert.ok(!JSON.stringify(session.history).includes(marker)); + assert.ok(!JSON.stringify(session.messages).includes(marker)); + assert.ok(!JSON.stringify(events).includes(marker)); + assert.ok(!(await disk(session)).includes(marker)); + release(deny); + await assert.rejects(run); + assert.ok(!JSON.stringify(events).includes(marker)); + assert.ok(!JSON.stringify(session.history).includes(marker)); + assert.ok(!(await disk(session)).includes(marker)); + }); +} + +for (const [shape, edits] of [ + ["JSON string", { edits: JSON.stringify([{ oldText: "before", newText: "after" }]) }], + ["single edit", { edits: { oldText: "before", newText: "after" } }], + ["legacy", { oldText: "before", newText: "after" }], +] as const) { + test(`native edit preparation preserves approved ${shape} arguments`, async () => { + const args = { path: "edit.txt", ...edits }; + const { session, cwd } = await fixture(undefined, [ + answer("", [{ id: "edit", name: "edit", arguments: args }]), + answer("Done"), + ]); + await writeFile(join(cwd, "edit.txt"), "before"); + await session.prompt("Edit the file"); + assert.equal(await readFile(join(cwd, "edit.txt"), "utf8"), "after"); + const assistant = session.history.find( + (message) => message.role === "assistant", + )!; + const call = assistant.content.find((block) => block.type === "toolCall")!; + assert.deepEqual(call.arguments, args); + const saved = (await disk(session)) + .trim().split("\n").map((line) => JSON.parse(line)); + assert.deepEqual( + saved.find((entry) => entry.message?.role === "assistant").message.content + .find((block: { type: string }) => block.type === "toolCall").arguments, + args, + ); + }); +} + +for (const decision of ["deny", "replace"] as const) { + test(`split-turn compaction waits for admission: ${decision}`, async () => { + let release!: (result: AdmissionResponse) => void; + let reached!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + const seen = new Promise((resolve) => { reached = resolve; }); + let candidate: Record = {}; + const { session, cwd } = await fixture(async (kind, body) => { + if (kind === "compaction_summary") { + candidate = body; + reached(); + return pending; + } + return kind === "provider_context" ? { ...allow, receipt: "receipt" } : allow; + }, [ + answer("", [{ id: "read", name: "read", arguments: { path: "notes.txt" } }]), + answer("Done"), + answer("RAW_SUMMARY"), + ]); + await writeFile(join(cwd, "notes.txt"), "Approved note"); + await session.prompt("Read notes.txt"); + session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); + const before = structuredClone(session.history); + const saved = await disk(session); + const compact = session.compact(); + const settled = decision === "deny" ? assert.rejects(compact) : compact; + await seen; + // Native compaction appends file-operation text; that must be admitted too. + assert.ok(String(candidate.text).includes("RAW_SUMMARY")); + assert.ok(String(candidate.text).includes("notes.txt")); + assert.deepEqual(session.history, before); + assert.equal(await disk(session), saved); + release({ + decision, + replacement: decision === "replace" ? { ...candidate, text: "APPROVED_SUMMARY" } : null, + receipt: null, + }); + await settled; + if (decision === "deny") { + assert.deepEqual(session.history, before); + assert.equal(await disk(session), saved); + } else { + const entry = session.entries.find((entry) => entry.type === "compaction")!; + assert.equal(entry.summary, "APPROVED_SUMMARY"); + assert.equal(entry.details, undefined); + assert.ok(JSON.stringify(session.history).includes("APPROVED_SUMMARY")); + assert.ok((await disk(session)).includes("Read notes.txt"), "history is append-only"); + } + for (const snapshot of [JSON.stringify(session.history), await disk(session)]) + assert.ok(!snapshot.includes("RAW_SUMMARY")); + }); +} + +test("real bash is bounded before Pi can spill an unchecked output log", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-bash-test-")); + const before = (await readdir(tmpdir())).filter((name) => + name.startsWith("pi-bash-"), + ); + const bash = projectTools(cwd).find((tool) => tool.name === "bash")!; + try { + await bash.execute("call", { command: "head -c 100000 /dev/zero" }); + } catch { + /* Pi reports the bounded operation as a tool error. */ + } + const after = (await readdir(tmpdir())).filter((name) => + name.startsWith("pi-bash-"), + ); + assert.deepEqual(after, before); +}); + +test("provider errors and partial responses never become history", async () => { + const error = { + ...answer("PARTIAL_RESPONSE"), + stopReason: "error" as const, + errorMessage: "RAW_PROVIDER_ERROR", + }; + const { session } = await fixture(undefined, [error]); + await assert.rejects(session.prompt("hello")); + assert.equal(session.history.length, 1); + assert.ok(!JSON.stringify(session.entries).includes("PARTIAL_RESPONSE")); + assert.ok(!(await disk(session)).includes("RAW_PROVIDER_ERROR")); +}); + +test("denied tool result stops model continuation and unexecuted calls", async () => { + const { session, cwd, requests } = await fixture( + async (kind, body) => { + if ( + kind === "tool_result" && + JSON.stringify(body).includes("FORBIDDEN_OUTPUT") + ) + return deny; + return kind === "provider_context" + ? { ...allow, receipt: "receipt" } + : allow; + }, + [ + answer("", [ + { id: "read", name: "read", arguments: { path: "candidate.txt" } }, + { + id: "write", + name: "write", + arguments: { + path: "must-not-exist", + content: "unchecked continuation", + }, + }, + ]), + ], + ); + await writeFile(join(cwd, "candidate.txt"), "FORBIDDEN_OUTPUT"); + await assert.rejects(session.prompt("Use the tools")); + assert.equal(requests.length, 1); + assert.equal( + session.history.filter((message) => message.role === "toolResult").length, + 2, + ); + await assert.rejects(readFile(join(cwd, "must-not-exist"))); + assert.ok(!JSON.stringify(session.entries).includes("FORBIDDEN_OUTPUT")); +}); + +test("unavailable tool admission leaves an unfinished session stopped", async () => { + const { session, requests } = await fixture( + async (kind) => { + if (kind === "tool_result") throw new Error("SERVICE_UNAVAILABLE"); + return kind === "provider_context" + ? { ...allow, receipt: "receipt" } + : allow; + }, + [answer("", [{ id: "call", name: "missing", arguments: {} }])], + ); + await assert.rejects(session.prompt("hello")); + assert.equal(session.isStopped, true); + assert.equal(requests.length, 1); + await assert.rejects(session.prompt("continue")); + assert.equal( + session.history.filter((message) => message.role === "toolResult").length, + 0, + ); +}); + +test("project context is checked before any model request or message write", async () => { + await assert.rejects( + fixture(async (kind, body) => { + assert.equal(kind, "system_context"); + assert.ok(String(body.text).includes("Use the project tools.")); + assert.ok(String(body.text).includes("Example skill")); + return deny; + }), + ); +}); + +test("cancelled admission cannot append even when the service subsequently allows", async () => { + const { session, requests } = await fixture(async (kind) => { + if (kind === "user_message") session.agent.abort(); + return allow; + }); + await assert.rejects(session.prompt("CANCELLED")); + assert.deepEqual(session.entries, []); + assert.equal(requests.length, 0); +}); + +for (const enabled of [true, false]) { + test(`overflow respects auto-compaction ${enabled}`, async () => { + const overflow = { + ...answer(""), + stopReason: "error" as const, + errorMessage: "exceeds the context window", + }; + const { session, requests, kinds } = await fixture(undefined, [ + answer("first"), + overflow, + answer("summary"), + answer("retry result"), + ]); + await session.prompt("first turn"); + session.setAutoCompactionEnabled(enabled); + session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 5 } }); + if (enabled) await session.prompt("next turn"); + else await assert.rejects(session.prompt("next turn"), /Context is too large/); + assert.equal(requests.length, enabled ? 4 : 2); + assert.equal( + kinds.filter((kind) => kind === "compaction_summary").length, + enabled ? 1 : 0, + ); + assert.equal(JSON.stringify(session.history).includes("retry result"), enabled); + assert.ok(!(await disk(session)).includes("exceeds the context window")); + }); +} + +test("system rebuilds remain private while user admission is pending or denied", async () => { + let release!: () => void; + let reached!: () => void; + const hold = new Promise((resolve) => { + release = resolve; + }); + const seen = new Promise((resolve) => { + reached = resolve; + }); + let denyUser = true; + const { session, requests } = await fixture(async (kind, body) => { + if (kind === "system_context") return { + ...allow, + decision: "replace", + replacement: { ...body, text: "APPROVED_SYSTEM" }, + }; + if (kind === "user_message" && denyUser) { + reached(); + await hold; + return deny; + } + return kind === "provider_context" ? { ...allow, receipt: "receipt" } : allow; + }); + assert.equal(session.systemPrompt, "APPROVED_SYSTEM"); + const running = session.prompt("denied"); + const rejected = assert.rejects(running); + await seen; + assert.equal(session.agent.state.systemPrompt, "APPROVED_SYSTEM"); + release(); + await rejected; + assert.equal(session.systemPrompt, "APPROVED_SYSTEM"); + assert.equal(session.messages.length, 0); + denyUser = false; + await session.prompt("allowed"); + assert.equal(requests[0].systemPrompt, "APPROVED_SYSTEM"); + assert.equal(session.systemPrompt, "APPROVED_SYSTEM"); +}); + +test("native session persists each approved message once and never renders tool details", async () => { + const { session, cwd } = await fixture(undefined, [ + answer("", [ + { id: "read", name: "read", arguments: { path: "notes.txt" } }, + ]), + answer("Done"), + ]); + await writeFile(join(cwd, "notes.txt"), "Approved file"); + const tool = session.agent.state.tools.find((tool) => tool.name === "read")!; + const execute = tool.execute; + tool.execute = async (id, args, signal, onUpdate) => { + onUpdate?.({ + content: [{ type: "text", text: "UNCHECKED_PROGRESS" }], + details: undefined, + }); + const result = await execute(id, args, signal); + return { ...result, details: { diff: "UNCHECKED_DETAILS" } }; + }; + const events: unknown[] = []; + session.subscribe((event) => { + events.push(structuredClone(event)); + }); + await session.prompt("Read notes.txt"); + assert.equal(session.messages.length, 4); + assert.equal( + session.entries.filter((entry) => entry.type === "message").length, + 4, + ); + const saved = (await disk(session)) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.equal(saved.filter((entry) => entry.type === "message").length, 4); + assert.ok(JSON.stringify(events).includes("Approved file")); + for (const snapshot of [ + JSON.stringify(events), + JSON.stringify(session.messages), + await disk(session), + ]) + assert.ok(!snapshot.includes("UNCHECKED")); +}); + +for (const mode of ["steer", "followUp"] as const) { + test(`native ${mode} queue admits expanded input before transcript insertion`, async () => { + let release!: () => void; + let reached!: () => void; + const seen = new Promise((resolve) => { + reached = resolve; + }); + const hold = new Promise((resolve) => { + release = resolve; + }); + const { session, requests } = await fixture( + async (kind, body) => { + if (kind === "assistant_message" && body.text === "First") { + reached(); + await hold; + } + if ( + kind === "user_message" && + String(body.text).includes("SKILL_CANDIDATE") + ) + return { + ...allow, + decision: "replace", + replacement: { + ...body, + text: String(body.text).replace( + "SKILL_CANDIDATE", + "APPROVED_SKILL", + ), + }, + }; + return kind === "provider_context" + ? { ...allow, receipt: "receipt" } + : allow; + }, + [answer("First"), answer("Second")], + ); + const running = session.prompt("hello"); + await seen; + await session[mode]("/skill:example"); + assert.ok(!JSON.stringify(session.messages).includes("SKILL_CANDIDATE")); + assert.ok(!(await disk(session)).includes("SKILL_CANDIDATE")); + release(); + await running; + assert.equal(requests.length, 2); + assert.ok(JSON.stringify(requests[1]).includes("APPROVED_SKILL")); + assert.ok(!JSON.stringify(session.entries).includes("SKILL_CANDIDATE")); + assert.equal(session.agent.hasQueuedMessages(), false); + assert.equal( + session.getSteeringMessages().length + + session.getFollowUpMessages().length, + 0, + ); + }); +} + +test("abort settles the native lifecycle without saving late content and permits the next prompt", async () => { + let release!: () => void; + let reached!: () => void; + const seen = new Promise((resolve) => { + reached = resolve; + }); + const hold = new Promise((resolve) => { + release = resolve; + }); + const { session } = await fixture( + async (kind, body) => { + if (kind === "assistant_message" && body.text === "LATE_RESPONSE") { + reached(); + await hold; + } + return kind === "provider_context" + ? { ...allow, receipt: "receipt" } + : allow; + }, + [answer("LATE_RESPONSE"), answer("Next reply")], + ); + const running = session.prompt("first"); + await seen; + const rejected = assert.rejects(running); + const abort = session.abort(); + assert.equal(session.agent.state.isStreaming, true); + release(); + await Promise.all([rejected, abort]); + await session.agent.waitForIdle(); + assert.equal(session.isStreaming, false); + assert.ok(!JSON.stringify(session.messages).includes("LATE_RESPONSE")); + assert.ok(!(await disk(session)).includes("LATE_RESPONSE")); + await session.prompt("next"); + assert.ok(JSON.stringify(session.history).includes("Next reply")); +}); + +test("native alternate writes fail closed; /new reuses the admission factory", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-runtime-test-")); + const runtime = await createAdmissionRuntime({ + cwd, + agentDir: join(cwd, "agent"), + sessionDir: join(cwd, "sessions"), + model, + apiKey: "placeholder", + admission: new Admission(async (kind) => + kind === "user_message" ? deny : allow, + ), + }); + await runtime.session.bindExtensions({}); + const before = JSON.stringify(runtime.session.messages); + const entries = runtime.session.sessionManager.getEntries(); + await assert.rejects( + runtime.session.executeBash("touch must-not-exist"), + /not supported/, + ); + assert.throws( + () => + runtime.session.recordBashResult("bad", { + output: "UNCHECKED", + exitCode: 0, + cancelled: false, + truncated: false, + }), + /not supported/, + ); + await assert.rejects( + runtime.session.sendCustomMessage({ + customType: "test", + content: "UNCHECKED", + display: true, + }), + /not supported/, + ); + await assert.rejects( + runtime.session.navigateTree("missing"), + /not supported/, + ); + await assert.rejects(runtime.session.setModel(model), /not supported/); + assert.throws( + () => runtime.session.setSessionName("UNCHECKED"), + /not supported/, + ); + await assert.rejects( + runtime.switchSession("/does/not/exist"), + /not supported/, + ); + await assert.rejects( + runtime.importFromJsonl("/does/not/exist"), + /not supported/, + ); + await assert.rejects(runtime.fork("missing"), /not supported/); + assert.equal(JSON.stringify(runtime.session.messages), before); + assert.deepEqual(runtime.session.sessionManager.getEntries(), entries); + await assert.rejects(readFile(join(cwd, "must-not-exist"))); + const previous = runtime.session; + previous.setAutoCompactionEnabled(false); + previous.setSteeringMode("all"); + previous.setFollowUpMode("all"); + assert.equal(previous.agent.sessionId, previous.sessionId); + await runtime.newSession(); + assert.notEqual(runtime.session, previous); + assert.equal(runtime.session.autoCompactionEnabled, false); + assert.equal(runtime.session.steeringMode, "all"); + assert.equal(runtime.session.followUpMode, "all"); + assert.notEqual(runtime.session.sessionId, previous.sessionId); + assert.equal(runtime.session.agent.sessionId, runtime.session.sessionId); + await runtime.session.bindExtensions({}); + await assert.rejects(runtime.session.prompt("DENIED")); + assert.equal(runtime.session.messages.length, 0); + assert.equal(runtime.session.sessionManager.getEntries().length, 0); + await runtime.dispose(); +}); diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/tsconfig.json b/projects/egress-gate/examples/pi-attested-admission/pi-harness/tsconfig.json new file mode 100644 index 00000000..8969e893 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "rootDir": ".", + "outDir": "dist" + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/projects/egress-gate/examples/pi-attested-admission/policy.yaml b/projects/egress-gate/examples/pi-attested-admission/policy.yaml new file mode 100644 index 00000000..041bf923 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/policy.yaml @@ -0,0 +1,83 @@ +version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + model_provider: + name: Configured model endpoint + endpoints: + - host: api.example.com # prepare replaces this with the models.json endpoint. + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: POST + path: /v1/chat/completions + binaries: + - { path: /usr/bin/node } + - { path: /usr/local/bin/node } + - { path: /usr/bin/curl } + + admission: + name: Authenticated admission API (not a model endpoint) + endpoints: + - host: host.docker.internal # prepare replaces this with EGRESS_GATE_HOST. + port: 5443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: POST + path: /v1/admission + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/bin/curl } + +network_middlewares: + pi_egress_gate: + name: Admit rendered Pi prompts and inspect provider requests + middleware: pi-egress + order: 0 + config: + gates: + - name: deny-marker + kind: regex + scan: + kind: body + action: + kind: deny + pattern_catalog: + entities: + - name: unsafe-marker + rules: + - name: exact-deny-marker + pattern: DENY_THIS + confidence: high + - name: replace-marker + kind: regex + scan: + kind: body + action: + kind: replace + template: "[REDACTED]" + pattern_catalog: + entities: + - name: replacement-marker + rules: + - name: exact-replacement-marker + pattern: REDACT_THIS + confidence: high + default_decision: allow + on_error: fail_closed + endpoints: + include: + - api.example.com # prepare replaces this with the models.json endpoint. diff --git a/projects/egress-gate/examples/pi-attested-admission/prepare.py b/projects/egress-gate/examples/pi-attested-admission/prepare.py new file mode 100644 index 00000000..7a1af306 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/prepare.py @@ -0,0 +1,352 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Prepare one host-owned demo configuration. Never run inside the sandbox.""" + +from __future__ import annotations + +import argparse +import ipaddress +import json +import os +import secrets +import shutil +import ssl +import sys +from datetime import UTC, datetime, timedelta +from http.client import HTTPSConnection +from pathlib import Path +from urllib.parse import urlparse + +import jwt +import yaml +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + + +def prepare( + example: Path, + state: Path, + host: str, + gateway_public_key: Path, + gateway_issuer: str, + model_selection: str = "", +) -> None: + """Keep keys outside the image; copy only the public CA and explicit demo files.""" + endpoint = urlparse(f"https://{host}:5443") + if endpoint.hostname != host or endpoint.port != 5443 or endpoint.path: + raise ValueError("Use a DNS hostname or IPv4 address, without a URL or port") + catalog, selection, base_url = select_model( + example / "models.json", model_selection + ) + target = urlparse(base_url) + if ( + target.scheme != "https" + or not target.hostname + or target.username + or target.query + or target.fragment + ): + raise ValueError( + "The model must use an HTTPS endpoint without credentials or query" + ) + if target.hostname == host: + raise ValueError("Model and admission endpoints must be separate") + public_key = serialization.load_pem_public_key(gateway_public_key.read_bytes()) + if not isinstance(public_key, ed25519.Ed25519PublicKey): + raise ValueError("Provide the gateway's Ed25519 public signing key") + os.umask(0o077) + state.mkdir(parents=True, exist_ok=True) + tls = state / "tls" + certificate = tls / "server/tls.crt" + if ( + not certificate.exists() + or (state / "service-host").read_text() != host + or x509.load_pem_x509_certificate(certificate.read_bytes()).not_valid_after_utc + <= datetime.now(UTC) + ): + _create_certificates(tls, host) + print("Service TLS created: install tls/ca.crt in the gateway's trust config.") + (state / "service-host").write_text(host) + model_path = target.path.rstrip("/") + "/chat/completions" + policy = yaml.safe_load((example / "policy.yaml").read_text()) + model_endpoint = policy["network_policies"]["model_provider"]["endpoints"][0] + model_endpoint.update(host=target.hostname, port=target.port or 443) + model_endpoint["rules"][0]["allow"]["path"] = model_path + policy["network_policies"]["admission"]["endpoints"][0]["host"] = host + binding = policy["network_middlewares"]["pi_egress_gate"] + binding["endpoints"]["include"] = [target.hostname] + (state / "policy.yaml").write_text(yaml.safe_dump(policy, sort_keys=False)) + for name, provider_host, port, variable in [ + ("model", target.hostname, target.port or 443, "PI_MODEL_API_KEY"), + ("admission", host, 5443, "EGRESS_ADMISSION_TOKEN"), + ]: + profile = { + "id": f"pi-admission-{name}", + "display_name": f"Pi example {name}", + "category": "inference" if name == "model" else "other", + "credentials": [ + {"name": "token", "env_vars": [variable], "required": True} + ], + "discovery": {"credentials": ["token"]}, + "endpoints": [ + { + "host": provider_host, + "port": port, + "protocol": "rest", + "access": "read-write", + "enforcement": "enforce", + } + ], + "binaries": ["/usr/local/bin/node", "/usr/bin/curl"], + } + (state / f"{name}-provider.yaml").write_text(yaml.safe_dump(profile)) + config_path = state / "admission.json" + token = ( + json.loads(config_path.read_text())["bearer_token"] + if config_path.exists() + else secrets.token_urlsafe(32) + ) + audience = "urn:openshell:extension:middleware:pi-egress" + config = { + "listen": "0.0.0.0:5443", + "tls_certificate": str(tls / "server/tls.crt"), + "tls_private_key": str(tls / "server/tls.key"), + "gateway_public_key": str(gateway_public_key.resolve()), + "gateway_issuer": gateway_issuer, + "gateway_audience": audience, + "middleware_name": "pi-egress", + "bearer_token": token, + "sandbox_id_file": str(state / "sandbox-id"), + "provider_target": { + "scheme": "https", + "host": target.hostname, + "port": target.port or 443, + "method": "POST", + "path": model_path, + "query": "", + }, + "policy": binding["config"], + } + config_path.write_text(json.dumps(config, indent=2) + "\n") + # JSON string quoting is also valid for these TOML basic string values. + quote = json.dumps + registration = f"""[[openshell.supervisor.middleware]] +name = "pi-egress" +grpc_endpoint = "https://{host}:50051" +tls_ca_cert_path = {quote(str(tls / "ca.crt"))} +audience = "{audience}" +max_payload_bytes = 4194304 +timeout = "10s" +""" + (state / "middleware.toml").write_text(registration) + image = state / "image" + # Recreate only this generated build context, so removed source/config files + # cannot survive a subsequent prepare. Host keys and runtime state stay put. + if image.exists(): + shutil.rmtree(image) + image.mkdir() + for directory in ("pi-harness/src", "pi-harness/test"): + shutil.copytree(example / directory, image / directory, dirs_exist_ok=True) + for name in ("package.json", "package-lock.json", "tsconfig.json"): + shutil.copyfile(example / "pi-harness" / name, image / "pi-harness" / name) + # This is one explicit project, not a recursive upload of the operator's cwd. + for name in ("AGENTS.md", "notes.txt", ".pi/skills/review/SKILL.md"): + destination = image / "project" / name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(example / "project" / name, destination) + (image / "models.json").write_text(json.dumps(catalog, indent=2) + "\n") + (image / "model-selection.json").write_text(json.dumps(selection) + "\n") + shutil.copyfile(example / "sandbox/Dockerfile", image / "Dockerfile") + shutil.copyfile(tls / "ca.crt", image / "admission-ca.crt") + print(f"Selected model: {selection['provider']}/{selection['id']}") + + +def select_model( + path: Path, requested: str +) -> tuple[dict[str, object], dict[str, str], str]: + """Stage one native Pi model; provider credentials remain owned by OpenShell.""" + providers = json.loads(path.read_text()).get("providers") + if not isinstance(providers, dict): + raise ValueError( + "Use Pi's native models.json providers catalog; see models.json.example" + ) + choices = [ + (provider_id, provider, model) + for provider_id, provider in providers.items() + for model in provider.get("models", []) + if not requested or f"{provider_id}/{model['id']}" == requested + ] + if len(choices) != 1: + raise ValueError( + "Set PI_MODEL=provider/model to select exactly one declared model" + ) + provider_id, provider, model = choices[0] + overrides = provider.get("modelOverrides", {}).get(model["id"], {}) + if any(config.get("headers") for config in (provider, model, overrides)): + raise ValueError("Custom model headers are unsupported; use PI_MODEL_API_KEY") + if provider.get("oauth"): + raise ValueError( + "Custom provider authentication is unsupported; use PI_MODEL_API_KEY" + ) + if model.get("api", provider.get("api")) != "openai-completions": + raise ValueError("Select an openai-completions model for this example") + base_url = model.get("baseUrl", provider.get("baseUrl")) + if not isinstance(base_url, str): + raise ValueError("Declare the selected model's baseUrl in models.json") + selected_provider = { + key: provider[key] for key in ("api", "baseUrl", "compat") if key in provider + } + selected_provider["models"] = [model] + if overrides: + selected_provider["modelOverrides"] = {model["id"]: overrides} + return ( + {"providers": {provider_id: selected_provider}}, + {"provider": provider_id, "id": model["id"]}, + base_url, + ) + + +def _discover_gateway(gateway: dict[str, str]) -> tuple[bytes, str]: + """Use the CLI's registered endpoint and existing client TLS, never new keys.""" + endpoint = urlparse(gateway["endpoint"]) + name = gateway["name"] + if endpoint.scheme != "https" or not endpoint.hostname or gateway["auth"] != "mtls": + raise ValueError("This demo requires a registered HTTPS/mTLS gateway") + if not name or Path(name).name != name or name in (".", ".."): + raise ValueError("Invalid gateway name") + config = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + tls = config / "openshell/gateways" / name / "mtls" + context = ssl.create_default_context(cafile=str(tls / "ca.crt")) + # OpenShell's generated certificates omit extensions required by Python 3.13's + # strict X.509 mode. Retain CA/signature, expiry and hostname verification. + context.verify_flags &= ~ssl.VERIFY_X509_STRICT + context.load_cert_chain(tls / "tls.crt", tls / "tls.key") + connection = HTTPSConnection( + endpoint.hostname, endpoint.port, context=context, timeout=10 + ) + try: + print(f"Discovering gateway identity from {gateway['endpoint']}") + connection.request("GET", "/.well-known/openid-configuration") + response = connection.getresponse() + if response.status != 200: + raise ValueError(f"Gateway discovery returned HTTP {response.status}") + discovery = json.load(response) + issuer = discovery["issuer"] + if not isinstance(issuer, str) or not issuer: + raise ValueError("Gateway discovery must provide a nonempty issuer") + jwks = urlparse(discovery["jwks_uri"]) + if (jwks.scheme, jwks.netloc) != (endpoint.scheme, endpoint.netloc): + raise ValueError( + "Gateway signing keys must come from the same HTTPS origin" + ) + connection.request("GET", jwks.path + (f"?{jwks.query}" if jwks.query else "")) + response = connection.getresponse() + if response.status != 200: + raise ValueError( + f"Gateway signing-key discovery returned HTTP {response.status}" + ) + keys = json.load(response)["keys"] + if len(keys) != 1: + raise ValueError("This demo expects one gateway signing key") + key = jwt.PyJWK.from_dict(keys[0]).key + if not isinstance(key, ed25519.Ed25519PublicKey): + raise ValueError("Gateway must publish an Ed25519 public signing key") + return key.public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + ), issuer + finally: + connection.close() + + +def _create_certificates(tls: Path, host: str) -> None: + now = datetime.now(UTC) + ca_key = ec.generate_private_key(ec.SECP256R1()) + ca_name = x509.Name( + [x509.NameAttribute(NameOID.COMMON_NAME, "Pi admission demo CA")] + ) + ca = ( + x509.CertificateBuilder() + .subject_name(ca_name) + .issuer_name(ca_name) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=5)) + .not_valid_after(now + timedelta(days=30)) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .sign(ca_key, hashes.SHA256()) + ) + tls.mkdir(exist_ok=True) + (tls / "ca.crt").write_bytes(ca.public_bytes(serialization.Encoding.PEM)) + # The CA key is not needed again; each setup has a 30-day local trust bundle. + key = ec.generate_private_key(ec.SECP256R1()) + certificate = ( + x509.CertificateBuilder() + .subject_name( + x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "pi-admission-service")]) + ) + .issuer_name(ca_name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=5)) + .not_valid_after(now + timedelta(days=30)) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), + critical=False, + ) + .add_extension( + x509.SubjectAlternativeName( + [x509.DNSName("localhost"), _service_name(host)] + ), + critical=False, + ) + .sign(ca_key, hashes.SHA256()) + ) + directory = tls / "server" + directory.mkdir(exist_ok=True) + (directory / "tls.crt").write_bytes( + certificate.public_bytes(serialization.Encoding.PEM) + ) + (directory / "tls.key").write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + + +def _service_name(host: str) -> x509.GeneralName: + try: + return x509.IPAddress(ipaddress.ip_address(host)) + except ValueError: + return x509.DNSName(host) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--state", type=Path, required=True) + parser.add_argument("--host", required=True) + parser.add_argument("--gateway", required=True) + parser.add_argument("--model", default="") + args = parser.parse_args() + gateways = json.load(sys.stdin) + gateway = next((item for item in gateways if item["name"] == args.gateway), None) + if gateway is None: + parser.error("Gateway is not registered; use openshell gateway add first") + public_key, issuer = _discover_gateway(gateway) + os.umask(0o077) + args.state.mkdir(parents=True, exist_ok=True) + public_path = args.state.resolve() / "gateway-public.pem" + public_path.write_bytes(public_key) + prepare( + Path(__file__).resolve().parent, + args.state.resolve(), + args.host, + public_path, + issuer, + args.model, + ) diff --git a/projects/egress-gate/examples/pi-attested-admission/project/.pi/skills/review/SKILL.md b/projects/egress-gate/examples/pi-attested-admission/project/.pi/skills/review/SKILL.md new file mode 100644 index 00000000..22d559f8 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/project/.pi/skills/review/SKILL.md @@ -0,0 +1,6 @@ +--- +name: review +description: Review the small demo project using its real files. +--- +Read notes.txt and summarize what you find. This skill itself contains +REDACT_THIS so its expansion demonstrates replacement before history insertion. diff --git a/projects/egress-gate/examples/pi-attested-admission/project/AGENTS.md b/projects/egress-gate/examples/pi-attested-admission/project/AGENTS.md new file mode 100644 index 00000000..0e51298d --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/project/AGENTS.md @@ -0,0 +1,4 @@ +# Demo project + +Use the project tools when asked to inspect files. Report observations accurately. +This project demonstrates admission before conversation-history writes. diff --git a/projects/egress-gate/examples/pi-attested-admission/project/notes.txt b/projects/egress-gate/examples/pi-attested-admission/project/notes.txt new file mode 100644 index 00000000..e72101a0 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/project/notes.txt @@ -0,0 +1,2 @@ +This is a real file in the sandbox project. +REDACT_THIS is an intentionally harmless marker for the example replacement rule. diff --git a/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile b/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile new file mode 100644 index 00000000..ffd4c02b --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +FROM node:22.22.2-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl fd-find ripgrep git iproute2 iptables nftables \ + && ln -s /usr/bin/fdfind /usr/local/bin/fd \ + && useradd --create-home --uid 1001 sandbox \ + && rm -rf /var/lib/apt/lists/* +COPY admission-ca.crt /usr/local/share/ca-certificates/admission-ca.crt +RUN update-ca-certificates +WORKDIR /app +COPY pi-harness/package.json pi-harness/package-lock.json ./ +RUN npm ci --ignore-scripts --no-audit --no-fund +COPY pi-harness/ ./ +RUN npm run build && mkdir /app/agent +COPY models.json /app/models.json +COPY model-selection.json /app/model-selection.json +COPY --chown=sandbox:sandbox project/ /sandbox/project/ +RUN mkdir /sandbox/sessions && chown sandbox:sandbox /sandbox/sessions \ + && chmod -R a+rX /app /sandbox/project +ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt +WORKDIR /sandbox/project +USER sandbox diff --git a/projects/egress-gate/proto/supervisor_middleware.proto b/projects/egress-gate/proto/supervisor_middleware.proto index dbde411c..27fd804b 100644 --- a/projects/egress-gate/proto/supervisor_middleware.proto +++ b/projects/egress-gate/proto/supervisor_middleware.proto @@ -9,7 +9,8 @@ import "google/protobuf/empty.proto"; import "google/protobuf/struct.proto"; // SupervisorMiddleware lets an operator-run service inspect and transform -// sandbox HTTP egress before OpenShell injects credentials. +// sandbox HTTP requests and client WebSocket text messages before OpenShell +// injects credentials. service SupervisorMiddleware { // Describe returns the service manifest and declared bindings. rpc Describe(google.protobuf.Empty) returns (MiddlewareManifest); @@ -20,6 +21,16 @@ service SupervisorMiddleware { // EvaluateHttpRequest returns an allow, deny, or mutation decision for one // buffered HTTP request. rpc EvaluateHttpRequest(HttpRequestEvaluation) returns (HttpRequestResult); + + // EvaluateWebSocketSession opens one ordered, phase-specific stream for a + // single middleware stage and WebSocket upgrade attempt. The current + // implementation supports client-to-upstream text messages at + // PRE_CREDENTIALS; PRE_RETURN is reserved for upstream-to-client messages. + // A request may go unanswered when the session terminates. For every opened + // stage stream, OpenShell attempts at most one session_end before closing the + // stream when its transport is still writable. + rpc EvaluateWebSocketSession(stream WebSocketSessionEvent) + returns (stream WebSocketSessionEventResult); } // MiddlewareManifest describes one middleware service and the bindings it @@ -34,16 +45,26 @@ message MiddlewareManifest { string service_version = 2; // Bindings exposed by this middleware service. repeated MiddlewareBinding bindings = 3; + // Exact JWT audience this service verifies on inbound OpenShell calls. + // After authenticated Describe succeeds, OpenShell rejects the registration + // unless this matches the operator-configured audience. A strict verifier may + // reject an incorrect audience before returning this manifest. Empty skips + // this post-authentication consistency check. + string expected_audience = 4; } // MiddlewareBinding declares one operation and phase supported by a service. message MiddlewareBinding { - // Supported operation. V1 supports HTTP_REQUEST. + // Supported operation. SupervisorMiddlewareOperation operation = 1; - // Supported evaluation phase. V1 supports PRE_CREDENTIALS. + // Supported evaluation phase. PR 1 supports PRE_CREDENTIALS. PRE_RETURN is + // reserved for the return-path follow-up and is rejected by current + // manifest validation. SupervisorMiddlewarePhase phase = 2; - // Maximum request or replacement body this binding can process. - uint64 max_body_bytes = 3; + // Maximum logical payload or replacement this binding can process. For + // HTTP_REQUEST this is the request body; for WEBSOCKET_MESSAGE this is one + // complete message. Required for every payload-bearing operation. + uint64 max_payload_bytes = 3; // Optional binding-specific RPC timeout. Empty uses the operator-configured // service timeout, or the 500ms platform default when that is also omitted. // A non-empty value may shorten but cannot extend the operator timeout. @@ -104,12 +125,151 @@ message HttpHeader { enum SupervisorMiddlewareOperation { SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST = 1; + SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE = 2; } // Ordered phase within a supervisor operation. enum SupervisorMiddlewarePhase { SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS = 1; + SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN = 2; +} + +// Why OpenShell is ending a middleware stream. +enum WebSocketSessionEndReason { + WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED = 0; + WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE = 1; + WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT = 2; + WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD = 3; + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL = 4; + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5; + WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR = 6; + WEB_SOCKET_SESSION_END_REASON_CANCELLATION = 7; + WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED = 8; + WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL = 9; + // The middleware stage voluntarily declined inspection during preflight. + // This is a successful stage-local outcome, not a cancellation or denial of + // the WebSocket upgrade. + WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED = 10; +} + +// WebSocketSessionEvent is one ordered event in a stage-local stream. +// Message sequence numbers identify logical messages session-wide. A stage +// receives a strictly increasing subset of those numbers; gaps are valid when +// session messages are not delivered to that stage. +message WebSocketSessionEvent { + oneof event { + WebSocketPreflight preflight = 1; + WebSocketSessionStart session_start = 2; + WebSocketMessage message = 3; + WebSocketSessionEnd session_end = 4; + } +} + +// WebSocketPreflight lets a service decline this upgrade before OpenShell +// contacts upstream. It deliberately excludes query data, arbitrary request +// headers, and message payloads. +message WebSocketPreflight { + string session_id = 1; + SupervisorMiddlewarePhase phase = 2; + RequestContext context = 3; + // Admitted HTTP WebSocket-upgrade target. The method is GET, query is always + // empty, and path never includes a query string. + HttpRequestTarget target = 4; + repeated string requested_subprotocols = 5; + // Built-in middleware name or operator-owned registration name. + string middleware_name = 6; + google.protobuf.Struct config = 7; +} + +// WebSocketSessionStart reports bounded metadata known only after the +// upstream 101 response validates. Empty selected_subprotocol means none. +message WebSocketSessionStart { + string selected_subprotocol = 1; +} + +// WebSocketMessage contains one complete reconstructed logical message. +message WebSocketMessage { + // Session-global sequence starting at 1. Values delivered to one stage must + // strictly increase but need not be contiguous. Reject zero, duplicates, and + // regressions; accept gaps. + uint64 sequence = 1; + // One complete logical payload. Protobuf string decoding enforces UTF-8 for + // text messages. Raw frame mechanics are never exposed. Limited to 4 MiB by + // the platform and the binding-specific cap. + oneof payload { + string text = 2; + bytes binary = 3; + } +} + +// WebSocketSessionEnd is OpenShell's best-effort terminal notification for one +// opened stage stream. A stage receives at most one such notification. +message WebSocketSessionEnd { + WebSocketSessionEndReason reason = 1; +} + +// WebSocketPreflightAction is the service's one-time scoping decision. +enum WebSocketPreflightAction { + // Invalid response value handled according to the policy failure mode. + WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED = 0; + // Inspect this session after the upstream accepts the upgrade. + WEB_SOCKET_PREFLIGHT_ACTION_INSPECT = 1; + // Voluntarily decline inspection without denying the upgrade. This is a + // successful decision and does not engage on_error. + WEB_SOCKET_PREFLIGHT_ACTION_SKIP = 2; + // Authoritatively deny the upgrade before upstream contact. This is a + // successful decision and is enforced regardless of on_error. + WEB_SOCKET_PREFLIGHT_ACTION_DENY = 3; +} + +message WebSocketPreflightDecision { + WebSocketPreflightAction action = 1; + // Free-form service diagnostic. OpenShell never exposes this to the + // workload or security logs. Limited to 4 KiB before discarding. + string reason = 2; + // Optional stable machine-readable code for a deny decision. Because + // preflight runs before the HTTP upgrade completes, OpenShell may return + // this code to the requester. Codes follow the same format and 64-byte + // maximum as HttpRequestResult.reason_code. + string reason_code = 3; + // Audit-safe findings produced during preflight. At most 32 findings of at + // most 4 KiB encoded each are accepted. + repeated Finding findings = 4; + // Non-secret service-defined metadata included in diagnostics. At most 64 + // entries and 32 KiB of combined key/value data are accepted. + map metadata = 5; +} + +// WebSocketMessageResult contains the decision and optional replacement for +// one message. A replacement must use the same variant as the input payload. +message WebSocketMessageResult { + // Must exactly match the sequence of the corresponding WebSocketMessage. + uint64 sequence = 1; + Decision decision = 2; + // Absence preserves the input unchanged. Oneof presence distinguishes an + // empty replacement from no replacement, and string decoding enforces UTF-8. + oneof replacement { + string text = 3; + bytes binary = 4; + } + // Free-form service diagnostic. OpenShell never exposes this to the + // workload or security logs. Limited to 4 KiB before discarding. + string reason = 5; + // Optional stable machine-readable code for OCSF only. Unlike the HTTP + // reason_code, this value is never put in a WebSocket close frame. + string reason_code = 6; + repeated Finding findings = 7; + map metadata = 8; +} + +// WebSocketSessionEventResult is an evaluation result for a preflight or message +// event. Session start and end events do not produce results. +message WebSocketSessionEventResult { + oneof result { + WebSocketPreflightDecision preflight_decision = 1; + WebSocketMessageResult message_result = 2; + } } // RequestContext identifies the sandbox request being evaluated. @@ -120,11 +280,19 @@ message RequestContext { string sandbox_id = 2; // Workload process that originated the request, when available. Process originating_process = 3; + // Sandbox name that originated the request. For display and logging only. + // Names are workspace-scoped and may be reused for different sandbox + // instances, so consumers must use sandbox_id for authorization, persistence, + // durable correlation, and identity. + string sandbox_name = 4; + // Workspace the sandbox belongs to. For display and logging only; see the + // sandbox_name guidance above. + string workspace = 5; } // HttpRequestTarget describes the admitted HTTP destination and request target. message HttpRequestTarget { - // Request scheme, such as "http" or "https". + // Request scheme, such as "http", "https", "ws", or "wss". string scheme = 1; // Destination hostname selected by network policy. string host = 2; @@ -148,13 +316,16 @@ message Process { repeated string ancestors = 3; } -// Decision controls whether OpenShell continues processing the request. +// Decision controls whether OpenShell continues processing the current +// evaluation unit. enum Decision { // Invalid response value handled according to the policy failure mode. DECISION_UNSPECIFIED = 0; - // Continue processing the request and apply any returned mutations. + // Continue processing the current request or message and apply any returned + // mutations. DECISION_ALLOW = 1; - // Deny the request before credentials are injected or data is sent upstream. + // Reject the current request or message. The operation-specific result + // defines the enclosing protocol behavior. DECISION_DENY = 2; } diff --git a/projects/egress-gate/pyproject.toml b/projects/egress-gate/pyproject.toml index 99254472..044fe88d 100644 --- a/projects/egress-gate/pyproject.toml +++ b/projects/egress-gate/pyproject.toml @@ -10,9 +10,12 @@ authors = [ { name = "NVIDIA CORPORATION & AFFILIATES" }, ] dependencies = [ + "aiohttp>=3.12,<4", + "cryptography>=50,<51", "grpcio>=1.81.1,<2", "protobuf>=7.36,<8", # 7.36.0 fixes protobuf security advisories. "pydantic>=2.11,<3", + "pyjwt[crypto]>=2.10,<3", "pyyaml>=6,<7", "regex>=2026.7.19,<2027", "rich>=14,<16", diff --git a/projects/egress-gate/scripts/check.sh b/projects/egress-gate/scripts/check.sh index 0a02d5fb..7552e3fa 100755 --- a/projects/egress-gate/scripts/check.sh +++ b/projects/egress-gate/scripts/check.sh @@ -15,11 +15,14 @@ if [[ $# -gt 0 ]]; then uv_run+=(--python "$2") fi +npm --prefix examples/pi-attested-admission/pi-harness ci --ignore-scripts --no-audit --no-fund +npm --prefix examples/pi-attested-admission/pi-harness run build "${uv_run[@]}" pytest -q "${uv_run[@]}" ruff format --check . "${uv_run[@]}" ruff check . "${uv_run[@]}" ty check "${uv_run[@]}" python -c "import egress_gate" +npm --prefix examples/pi-attested-admission/pi-harness test "${uv_run[@]}" pip-audit \ --progress-spinner off \ --local diff --git a/projects/egress-gate/scripts/generate-bindings.sh b/projects/egress-gate/scripts/generate-bindings.sh new file mode 100755 index 00000000..ec867947 --- /dev/null +++ b/projects/egress-gate/scripts/generate-bindings.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +# Keep the protocol, bindings, lockfile and provenance under one owner. +exec uv run --frozen --project ../openshell-middleware-manager omm update . \ + --openshell-version v0.0.116 --check-command 'make check' diff --git a/projects/egress-gate/src/egress_gate/admission/__init__.py b/projects/egress-gate/src/egress_gate/admission/__init__.py new file mode 100644 index 00000000..27f4c82e --- /dev/null +++ b/projects/egress-gate/src/egress_gate/admission/__init__.py @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""First-class harness admission and attested-egress APIs.""" + +from egress_gate.admission.adapters import ( + AttestedEntries, + ContextEntryV1, + PiAssistantMessageV1, + PiAssistantToolCallV1, + PiMessageV1, + PiProviderContextV1, + PiTextContentV1, + PiToolResultV1, + ToolContextEntryV1, + UserContextEntryV1, + context_entries_subject, + extract_provider_entries, +) +from egress_gate.admission.canonical import canonical_json_bytes +from egress_gate.admission.models import ( + MAX_ADMISSION_BODY_BYTES, + PI_HARNESS_VERSION, + AdmissionDecision, + AdmissionHook, + AdmissionProvenance, + HarnessAdmissionContext, + HarnessAdmissionRequest, + HarnessAdmissionResult, +) +from egress_gate.admission.processor import ( + RECEIPT_HEADER, + AttestedEgressProcessor, + HarnessAdmissionProcessor, +) +from egress_gate.admission.receipts import ( + AgentAttestationClaimsV2, + ReceiptAuthority, + ReceiptVerificationError, +) + +__all__ = [ + "AdmissionDecision", + "AdmissionHook", + "AdmissionProvenance", + "AgentAttestationClaimsV2", + "AttestedEntries", + "AttestedEgressProcessor", + "ContextEntryV1", + "HarnessAdmissionContext", + "HarnessAdmissionProcessor", + "HarnessAdmissionRequest", + "HarnessAdmissionResult", + "MAX_ADMISSION_BODY_BYTES", + "PI_HARNESS_VERSION", + "PiAssistantMessageV1", + "PiAssistantToolCallV1", + "PiMessageV1", + "PiTextContentV1", + "PiToolResultV1", + "PiProviderContextV1", + "RECEIPT_HEADER", + "ReceiptAuthority", + "ReceiptVerificationError", + "ToolContextEntryV1", + "UserContextEntryV1", + "canonical_json_bytes", + "context_entries_subject", + "extract_provider_entries", +] diff --git a/projects/egress-gate/src/egress_gate/admission/adapters.py b/projects/egress-gate/src/egress_gate/admission/adapters.py new file mode 100644 index 00000000..3b6b3daa --- /dev/null +++ b/projects/egress-gate/src/egress_gate/admission/adapters.py @@ -0,0 +1,465 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Pi admission shapes and provider request validation.""" + +from __future__ import annotations + +import hashlib +import json +import math +from typing import Literal, TypeAlias + +from pydantic import ( + Field, + TypeAdapter, + ValidationError, + field_validator, + model_validator, +) + +from egress_gate.admission.canonical import canonical_json_bytes +from egress_gate.admission.models import ( + AdmissionHook, + HarnessAdmissionContext, +) +from egress_gate.base import StrictDomainModel +from egress_gate.errors import BodyFormatError, GateInputError +from egress_gate.request import HttpRequest +from egress_gate.request_content import JsonDocument +from egress_gate.string_validators import ScalarString +from egress_gate.timeout import Timeout + + +class AdmissionShapeError(ValueError): + """A content-safe signal that an admission shape is unsupported.""" + + +class AdmissionMutationError(ValueError): + """A content-safe signal that a Gate changed a read-only field.""" + + +class ProviderShapeError(ValueError): + """A content-safe signal that a provider request is unsupported.""" + + +PiMessageOrigin: TypeAlias = Literal["user", "system", "compaction_summary"] + + +class PiMessageV1(StrictDomainModel): + """Text-bearing message submitted by the managed Pi harness.""" + + schema_version: Literal["openshell.pi-message.v1"] + origin: PiMessageOrigin + text: ScalarString + + +class PiTextContentV1(StrictDomainModel): + """One Pi text content block.""" + + type: Literal["text"] + text: ScalarString + + +class PiToolResultV1(StrictDomainModel): + """Provider-relevant fields from one Pi tool-result message.""" + + schema_version: Literal["openshell.pi-tool-result.v1"] + tool_call_id: ScalarString + tool_name: ScalarString + content: tuple[PiTextContentV1, ...] + is_error: bool + + @field_validator("content", mode="before") + @classmethod + def _content_is_a_tuple(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + +class PiAssistantToolCallV1(StrictDomainModel): + """One immutable Pi assistant tool call.""" + + id: ScalarString + name: ScalarString + arguments: dict[str, object] + thought_signature: ScalarString | None = None + + +class PiThinkingContentV1(StrictDomainModel): + """Reasoning text with immutable provider replay metadata.""" + + text: ScalarString + signature: ScalarString | None = None + + +class PiAssistantMessageV1(StrictDomainModel): + """Replaceable assistant text and immutable tool calls.""" + + schema_version: Literal["openshell.pi-assistant-message.v1"] + text: ScalarString + tool_calls: tuple[PiAssistantToolCallV1, ...] + thinking: tuple[PiThinkingContentV1, ...] = () + + @field_validator("tool_calls", "thinking", mode="before") + @classmethod + def _tool_calls_are_a_tuple(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + +class UserContextEntryV1(StrictDomainModel): + """One ordered user entry sent to a provider.""" + + role: Literal["user"] + text: ScalarString + + +class ToolContextEntryV1(StrictDomainModel): + """One ordered tool entry sent to a provider.""" + + role: Literal["tool"] + tool_call_id: ScalarString + text: ScalarString + + +ContextEntryV1: TypeAlias = UserContextEntryV1 | ToolContextEntryV1 + + +class PiProviderContextV1(StrictDomainModel): + """Every provider-visible user and tool entry in order.""" + + schema_version: Literal["openshell.pi-provider-context.v1"] + entries: tuple[ContextEntryV1, ...] = Field(min_length=1) + + @field_validator("entries", mode="before") + @classmethod + def _entries_are_a_tuple(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + +HarnessNative: TypeAlias = ( + PiMessageV1 | PiToolResultV1 | PiAssistantMessageV1 | PiProviderContextV1 +) +AttestedEntries: TypeAlias = tuple[ContextEntryV1, ...] + + +def parse_pi_request( + body: bytes, context: HarnessAdmissionContext, timeout: Timeout +) -> HarnessNative: + """Validate one fixed Pi hook/schema pair before and after policy execution.""" + if context.harness != "pi": + raise AdmissionShapeError("harness admission shape is unsupported") + model = _PI_SHAPES[context.hook] + value = _load_json(body, AdmissionShapeError, timeout) + try: + native = model.model_validate(value, strict=True) + except ValidationError: + raise AdmissionShapeError("Pi request body is unsupported") from None + if native.schema_version != context.schema_version: + raise AdmissionShapeError("Pi request schema is unsupported") + if isinstance(native, PiMessageV1) and native.origin != _PI_ORIGINS[context.hook]: + raise AdmissionShapeError("Pi message origin is unsupported") + if isinstance(native, PiMessageV1 | PiProviderContextV1): + if canonical_json_bytes(native) != body: + raise AdmissionShapeError("Pi request body is not canonical JSON") + return native + + +def validate_pi_replacement(before: HarnessNative, after: HarnessNative) -> None: + """Allow text replacement without changing executable fields or entry identity.""" + if isinstance(before, PiAssistantMessageV1) and isinstance( + after, PiAssistantMessageV1 + ): + if before.tool_calls != after.tool_calls: + raise AdmissionMutationError("admission changed assistant tool calls") + if len(before.thinking) != len(after.thinking): + raise AdmissionMutationError("admission changed reasoning structure") + for original, replacement in zip(before.thinking, after.thinking): + if original.signature != replacement.signature or ( + original.signature + not in (None, "reasoning", "reasoning_content", "reasoning_text") + and original.text != replacement.text + ): + raise AdmissionMutationError("admission changed reasoning replay data") + elif isinstance(before, PiToolResultV1) and isinstance(after, PiToolResultV1): + if before.model_dump(exclude={"content"}) != after.model_dump( + exclude={"content"} + ): + raise AdmissionMutationError("admission changed tool-result metadata") + elif isinstance(before, PiProviderContextV1) and isinstance( + after, PiProviderContextV1 + ): + if tuple( + (e.role, getattr(e, "tool_call_id", None)) for e in before.entries + ) != tuple((e.role, getattr(e, "tool_call_id", None)) for e in after.entries): + raise AdmissionMutationError("admission changed provider-context structure") + + +class _ProviderCacheControl(StrictDomainModel): + type: Literal["ephemeral"] + ttl: Literal["1h"] | None = None + + +class _ProviderTextBlock(StrictDomainModel): + type: Literal["text"] + text: ScalarString + cache_control: _ProviderCacheControl | None = None + + +class _ProviderFunction(StrictDomainModel): + name: ScalarString + arguments: ScalarString + + +class _ProviderToolCall(StrictDomainModel): + id: ScalarString + type: Literal["function"] + function: _ProviderFunction + + +class _ProviderMessage(StrictDomainModel): + role: Literal["system", "developer", "user", "assistant", "tool"] + content: ScalarString | tuple[_ProviderTextBlock, ...] | None = None + name: ScalarString | None = None + tool_call_id: ScalarString | None = None + tool_calls: tuple[_ProviderToolCall, ...] = () + reasoning_content: ScalarString | None = None + reasoning: ScalarString | None = None + reasoning_text: ScalarString | None = None + # Provider-owned replay objects are inspected by request policy, never rewritten. + reasoning_details: tuple[dict[str, object], ...] = () + + @field_validator("content", "tool_calls", "reasoning_details", mode="before") + @classmethod + def _provider_sequences_are_tuples(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + @model_validator(mode="after") + def _role_fields_are_consistent(self) -> _ProviderMessage: + if self.role == "tool": + if self.content is None or self.tool_call_id is None or self.tool_calls: + raise ValueError("tool messages require content and tool_call_id") + elif self.tool_call_id is not None: + raise ValueError("only tool messages may carry tool_call_id") + if self.tool_calls and self.role != "assistant": + raise ValueError("only assistant messages may carry tool calls") + has_reasoning = any( + ( + self.reasoning, + self.reasoning_text, + self.reasoning_content, + self.reasoning_details, + ) + ) + if has_reasoning and self.role != "assistant": + raise ValueError("only assistant messages may carry reasoning") + if self.content is None and not self.tool_calls and not has_reasoning: + raise ValueError("messages require content or tool calls") + return self + + @model_validator(mode="after") + def _optional_fields_have_one_representation(self) -> _ProviderMessage: + if "content" not in self.model_fields_set: + raise ValueError("provider messages must include content") + if "name" in self.model_fields_set and self.name is None: + raise ValueError("provider message name cannot be null") + if "tool_call_id" in self.model_fields_set and self.tool_call_id is None: + raise ValueError("provider tool-call ID cannot be null") + if "tool_calls" in self.model_fields_set and not self.tool_calls: + raise ValueError("provider tool calls cannot be empty") + if ( + "reasoning_content" in self.model_fields_set + and self.reasoning_content is None + ): + raise ValueError("provider reasoning content cannot be null") + return self + + +class _ProviderFunctionDefinition(StrictDomainModel): + name: ScalarString + description: ScalarString + parameters: dict[str, object] + strict: bool | None = None + + @model_validator(mode="after") + def _optional_strict_is_not_null(self) -> _ProviderFunctionDefinition: + if "strict" in self.model_fields_set and self.strict is None: + raise ValueError("provider function strict cannot be null") + return self + + +class _ProviderTool(StrictDomainModel): + type: Literal["function"] + function: _ProviderFunctionDefinition + cache_control: _ProviderCacheControl | None = None + + +class _ProviderNamedChoiceFunction(StrictDomainModel): + name: ScalarString + + +class _ProviderNamedToolChoice(StrictDomainModel): + type: Literal["function"] + function: _ProviderNamedChoiceFunction + + +class _ProviderStreamOptions(StrictDomainModel): + include_usage: Literal[True] + + +class _ProviderReasoning(StrictDomainModel): + effort: ScalarString | None = None + enabled: bool | None = None + + +class _ProviderRequest(StrictDomainModel): + model: ScalarString + messages: tuple[_ProviderMessage, ...] + tools: tuple[_ProviderTool, ...] = () + tool_choice: Literal["auto", "none", "required"] | _ProviderNamedToolChoice = "auto" + temperature: int | float | None = Field(default=None, allow_inf_nan=False) + top_p: int | float | None = Field(default=None, allow_inf_nan=False) + max_completion_tokens: int | None = Field(default=None, ge=1) + max_tokens: int | None = Field(default=None, ge=1) + stream: Literal[True] + stream_options: _ProviderStreamOptions | None = None + store: Literal[False] | None = None + prompt_cache_key: ScalarString | None = None + prompt_cache_retention: Literal["24h"] | None = None + reasoning_effort: ScalarString | None = None + reasoning: _ProviderReasoning | None = None + enable_thinking: bool | None = None + + @field_validator("messages", "tools", mode="before") + @classmethod + def _provider_collections_are_tuples(cls, value: object) -> object: + return tuple(value) if isinstance(value, list | tuple) else value + + @model_validator(mode="after") + def _compatibility_fields_have_one_representation(self) -> _ProviderRequest: + if (self.max_completion_tokens is None) == (self.max_tokens is None): + raise ValueError("provider request requires exactly one max-token field") + for field_name in ("store", "enable_thinking", "stream_options"): + if ( + field_name in self.model_fields_set + and getattr(self, field_name) is None + ): + raise ValueError(f"provider request {field_name} cannot be null") + return self + + +def extract_provider_entries(request: HttpRequest, timeout: Timeout) -> AttestedEntries: + """Validate Chat Completions and extract only the receipt-covered context.""" + _validate_json_request(request) + value = _load_json(request.body, ProviderShapeError, timeout) + try: + provider = _PROVIDER_ADAPTER.validate_python(value, strict=True) + except ValidationError: + raise ProviderShapeError("provider request body is unsupported") from None + entries: list[ContextEntryV1] = [] + for message in provider.messages: + content = ( + "\n".join(block.text for block in message.content) + if isinstance(message.content, tuple) + else message.content + ) + if message.role == "user" and content is not None: + entries.append(UserContextEntryV1(role="user", text=content)) + elif message.role == "tool" and content is not None: + # The role validator requires this ID for every tool message. + assert message.tool_call_id is not None + entries.append( + ToolContextEntryV1( + role="tool", + tool_call_id=_provider_tool_call_id(message.tool_call_id), + text=content, + ) + ) + if not entries: + raise ProviderShapeError("provider request has no attested context entries") + return tuple(entries) + + +def context_entries_subject(entries: AttestedEntries) -> tuple[str, int]: + """Return the v2 hash and count for one ordered entry list.""" + body = json.dumps( + [entry.model_dump(mode="json") for entry in entries], + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(body).hexdigest(), len(entries) + + +def _validate_json_request(request: HttpRequest) -> None: + if request.target.method.upper() != "POST": + raise ProviderShapeError("provider request method is unsupported") + content_types = [ + header.value.strip().lower() + for header in request.headers + if header.name.lower() == "content-type" + ] + if content_types != ["application/json"]: + raise ProviderShapeError("provider request requires one JSON content type") + if any(header.name.lower() == "content-encoding" for header in request.headers): + raise ProviderShapeError("provider request content encoding is unsupported") + + +def _provider_tool_call_id(value: str) -> str: + return value.split("|", 1)[0] + + +def _load_json(body: bytes, error_type: type[ValueError], timeout: Timeout) -> object: + try: + JsonDocument.parse(body, timeout=timeout) + except (BodyFormatError, GateInputError): + raise error_type("request body is not canonical JSON") from None + try: + text = body.decode("utf-8", errors="strict") + return json.loads(text, parse_float=_finite_json_float) + except (UnicodeDecodeError, json.JSONDecodeError, RecursionError, ValueError): + raise error_type("request body is not canonical JSON") from None + + +def _finite_json_float(value: str) -> float: + number = float(value) + if not math.isfinite(number): + raise ValueError("JSON numbers must be finite") + return number + + +_PI_ORIGINS = { + AdmissionHook.USER_MESSAGE: "user", + AdmissionHook.SYSTEM_CONTEXT: "system", + AdmissionHook.COMPACTION_SUMMARY: "compaction_summary", +} +_PI_SHAPES: dict[AdmissionHook, type[HarnessNative]] = { + AdmissionHook.USER_MESSAGE: PiMessageV1, + AdmissionHook.SYSTEM_CONTEXT: PiMessageV1, + AdmissionHook.COMPACTION_SUMMARY: PiMessageV1, + AdmissionHook.TOOL_RESULT: PiToolResultV1, + AdmissionHook.ASSISTANT_MESSAGE: PiAssistantMessageV1, + AdmissionHook.PROVIDER_CONTEXT: PiProviderContextV1, +} +_PROVIDER_ADAPTER = TypeAdapter(_ProviderRequest) + + +__all__ = [ + "AdmissionMutationError", + "AdmissionShapeError", + "AttestedEntries", + "ContextEntryV1", + "PiMessageV1", + "PiAssistantMessageV1", + "PiAssistantToolCallV1", + "PiTextContentV1", + "PiToolResultV1", + "PiProviderContextV1", + "ProviderShapeError", + "ToolContextEntryV1", + "UserContextEntryV1", + "parse_pi_request", + "validate_pi_replacement", + "extract_provider_entries", + "context_entries_subject", +] diff --git a/projects/egress-gate/src/egress_gate/admission/canonical.py b/projects/egress-gate/src/egress_gate/admission/canonical.py new file mode 100644 index 00000000..60ce0fc9 --- /dev/null +++ b/projects/egress-gate/src/egress_gate/admission/canonical.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Stable JSON encoding for admission messages and receipt claims.""" + +from __future__ import annotations + +import json + +from egress_gate.base import StrictDomainModel + + +def canonical_json_bytes(value: StrictDomainModel) -> bytes: + """Encode a validated model with stable UTF-8 JSON semantics.""" + return json.dumps( + value.model_dump(mode="json"), + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") diff --git a/projects/egress-gate/src/egress_gate/admission/models.py b/projects/egress-gate/src/egress_gate/admission/models.py new file mode 100644 index 00000000..4a7ad6ba --- /dev/null +++ b/projects/egress-gate/src/egress_gate/admission/models.py @@ -0,0 +1,130 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Public, transport-neutral models for harness admission.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Literal + +from pydantic import Field, model_validator + +from egress_gate.base import StrictDomainModel +from egress_gate.constants import MAX_PROTO_FINDING_GROUPS +from egress_gate.request import HttpTarget +from egress_gate.result import ReasonCode, SourcedFinding +from egress_gate.string_validators import BoundedMetadataString, ScalarString + +MAX_ADMISSION_BODY_BYTES = 4 * 1024 * 1024 +PI_HARNESS_VERSION = "sdk-v1" + + +class AdmissionHook(StrEnum): + """Supported harness admission boundaries.""" + + USER_MESSAGE = "user_message" + SYSTEM_CONTEXT = "system_context" + TOOL_RESULT = "tool_result" + ASSISTANT_MESSAGE = "assistant_message" + COMPACTION_SUMMARY = "compaction_summary" + PROVIDER_CONTEXT = "provider_context" + + +class AdmissionDecision(StrEnum): + """Disposition of a harness request.""" + + ALLOW = "allow" + REPLACE = "replace" + DENY = "deny" + + +class AdmissionProvenance(StrictDomainModel): + """Request-local correlation assertions for one context addition.""" + + session_id: BoundedMetadataString + submission_id: BoundedMetadataString + + +class HarnessAdmissionRequest(StrictDomainModel): + """One complete harness-native context addition.""" + + request_body: bytes = Field(max_length=MAX_ADMISSION_BODY_BYTES, repr=False) + provenance: AdmissionProvenance + + +class HarnessAdmissionContext(StrictDomainModel): + """Trusted admission context stamped outside the workload.""" + + request_id: BoundedMetadataString + sandbox_id: BoundedMetadataString + middleware_name: BoundedMetadataString + harness: ScalarString + harness_version: Literal["sdk-v1"] + hook: AdmissionHook + schema_version: ScalarString + provider_target: HttpTarget + provider_adapter_schema: Literal["openai.request.v1"] + + +class HarnessAdmissionResult(StrictDomainModel): + """Atomic policy decision returned to a managed harness.""" + + hook: AdmissionHook + decision: AdmissionDecision + replacement_body: bytes | None = Field( + default=None, + max_length=MAX_ADMISSION_BODY_BYTES, + repr=False, + ) + attestation: bytes | None = Field( + default=None, + min_length=1, + max_length=8 * 1024, + repr=False, + ) + findings: tuple[SourcedFinding, ...] = Field( + default=(), max_length=MAX_PROTO_FINDING_GROUPS + ) + reason_code: ReasonCode | None = None + policy_fingerprint: ScalarString + + @model_validator(mode="after") + def _decision_contract_is_consistent(self) -> HarnessAdmissionResult: + if self.decision is AdmissionDecision.DENY: + if self.reason_code is None: + raise ValueError("denial requires a reason code") + if self.replacement_body is not None or self.attestation is not None: + raise ValueError("denial cannot carry a replacement or attestation") + else: + if self.reason_code is not None: + raise ValueError("allow decisions cannot carry a reason code") + if ( + self.decision is AdmissionDecision.REPLACE + and self.replacement_body is None + ): + raise ValueError("replace decisions require a replacement body") + if ( + self.decision is AdmissionDecision.ALLOW + and self.replacement_body is not None + ): + raise ValueError("allow decisions cannot carry a replacement body") + if (self.hook is AdmissionHook.PROVIDER_CONTEXT) != ( + self.attestation is not None + ): + raise ValueError( + "only provider-context admission carries an attestation" + ) + return self + + +__all__ = [ + "AdmissionDecision", + "AdmissionHook", + "AdmissionProvenance", + "HarnessAdmissionContext", + "HarnessAdmissionRequest", + "HarnessAdmissionResult", + "MAX_ADMISSION_BODY_BYTES", + "PI_HARNESS_VERSION", +] diff --git a/projects/egress-gate/src/egress_gate/admission/processor.py b/projects/egress-gate/src/egress_gate/admission/processor.py new file mode 100644 index 00000000..07a40f54 --- /dev/null +++ b/projects/egress-gate/src/egress_gate/admission/processor.py @@ -0,0 +1,278 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Harness-admission orchestration and attested network egress.""" + +from __future__ import annotations + +import base64 +import binascii +from typing import Literal + +from pydantic import ValidationError + +from egress_gate.admission.adapters import ( + AdmissionMutationError, + AdmissionShapeError, + PiProviderContextV1, + ProviderShapeError, + context_entries_subject, + extract_provider_entries, + parse_pi_request, + validate_pi_replacement, +) +from egress_gate.admission.canonical import canonical_json_bytes +from egress_gate.admission.models import ( + MAX_ADMISSION_BODY_BYTES, + AdmissionDecision, + AdmissionHook, + HarnessAdmissionContext, + HarnessAdmissionRequest, + HarnessAdmissionResult, +) +from egress_gate.admission.receipts import ReceiptAuthority, ReceiptVerificationError +from egress_gate.constants import MAX_AGENT_ATTESTATION_BYTES +from egress_gate.errors import EgressGateError, GateError, TimeoutExpiredError +from egress_gate.request import ( + HttpRequest, + RemoveHeaderMutation, + RequestContext, + RequestMutations, +) +from egress_gate.request_processor import RequestProcessor, apply_request_mutations +from egress_gate.result import ( + DecisionSourceKind, + EgressDecision, + EgressResult, + GateDecisionSource, +) +from egress_gate.timeout import Timeout + +RECEIPT_HEADER = "x-egress-admission" + + +class HarnessAdmissionProcessor: + """Apply the configured Gate pipeline through the fixed Pi admission shapes.""" + + def __init__( + self, + request_processor: RequestProcessor, + receipt_authority: ReceiptAuthority, + ) -> None: + fingerprint = request_processor.policy_fingerprint + if not fingerprint: + raise ValueError("admission requires a policy fingerprint") + self._request_processor = request_processor + self._receipt_authority = receipt_authority + self._policy_fingerprint = fingerprint + + def process( + self, + request: HarnessAdmissionRequest, + context: HarnessAdmissionContext, + *, + timeout: Timeout, + ) -> HarnessAdmissionResult: + """Return an explicit allow, replacement, or fail-closed denial.""" + try: + native = parse_pi_request(request.request_body, context, timeout) + projected_body = canonical_json_bytes(native) + projected = HttpRequest( + context=RequestContext( + request_id=context.request_id, + sandbox_id=context.sandbox_id, + ), + target=context.provider_target, + headers=(), + body=projected_body, + ) + gate_result = self._request_processor.process(projected, timeout=timeout) + timeout.raise_if_expired() + if gate_result.decision is EgressDecision.DENY: + return HarnessAdmissionResult( + hook=context.hook, + decision=AdmissionDecision.DENY, + findings=gate_result.findings, + reason_code=gate_result.reason_code, + policy_fingerprint=self._policy_fingerprint, + ) + if gate_result.request_mutations.header_mutations: + raise AdmissionMutationError("admission cannot mutate HTTP headers") + final_request = apply_request_mutations( + projected, gate_result.request_mutations + ) + final = parse_pi_request(final_request.body, context, timeout) + validate_pi_replacement(native, final) + encoded = canonical_json_bytes(final) + replacement = None if encoded == projected_body else encoded + if replacement is not None and len(replacement) > MAX_ADMISSION_BODY_BYTES: + raise AdmissionMutationError("admission replacement body is too large") + timeout.raise_if_expired() + attestation = None + if isinstance(final, PiProviderContextV1): + attestation = self._receipt_authority.issue_attestation( + *context_entries_subject(final.entries), + context, + request.provenance, + policy_fingerprint=self._policy_fingerprint, + ) + timeout.raise_if_expired() + return HarnessAdmissionResult( + hook=context.hook, + decision=( + AdmissionDecision.REPLACE + if replacement is not None + else AdmissionDecision.ALLOW + ), + replacement_body=replacement, + attestation=attestation, + findings=gate_result.findings, + policy_fingerprint=self._policy_fingerprint, + ) + except (AdmissionShapeError, AdmissionMutationError, ValidationError): + return self._deny("admission_contract_invalid", context.hook) + except TimeoutExpiredError: + return self._deny("admission_unavailable", context.hook) + except (EgressGateError, GateError, ValueError): + return self._deny("admission_unavailable", context.hook) + except Exception: + return self._deny("admission_unavailable", context.hook) + + def _deny(self, reason_code: str, hook: AdmissionHook) -> HarnessAdmissionResult: + return HarnessAdmissionResult( + hook=hook, + decision=AdmissionDecision.DENY, + reason_code=reason_code, + policy_fingerprint=self._policy_fingerprint, + ) + + +class AttestedEgressProcessor: + """Verify trusted agent attestation and reject context divergence.""" + + def __init__( + self, + request_processor: RequestProcessor, + receipt_authority: ReceiptAuthority, + *, + middleware_name: str, + harness_version: Literal["sdk-v1"], + ) -> None: + fingerprint = request_processor.policy_fingerprint + if not fingerprint: + raise ValueError("attested egress requires a policy fingerprint") + self._request_processor = request_processor + self._receipt_authority = receipt_authority + self._middleware_name = middleware_name + self._harness_version = harness_version + self._policy_fingerprint = fingerprint + + def process( + self, + request: HttpRequest, + *, + timeout: Timeout, + ) -> EgressResult: + """Deny any unattested or semantically changed provider request.""" + receipts = [ + h.value for h in request.headers if h.name.lower() == RECEIPT_HEADER + ] + if not receipts: + return self._deny("attestation_missing") + if ( + len(receipts) != 1 + or len(receipts[0]) > MAX_AGENT_ATTESTATION_BYTES * 4 // 3 + 4 + ): + return self._deny("attestation_malformed") + try: + agent_attestation = base64.b64decode( + receipts[0], altchars=b"-_", validate=True + ) + except (ValueError, binascii.Error): + return self._deny("attestation_malformed") + if ( + not agent_attestation + or len(agent_attestation) > MAX_AGENT_ATTESTATION_BYTES + ): + return self._deny("attestation_malformed") + request = request.model_copy( + update={ + "headers": tuple( + h for h in request.headers if h.name.lower() != RECEIPT_HEADER + ) + } + ) + try: + entries = extract_provider_entries(request, timeout) + subject_hash, entry_count = context_entries_subject(entries) + timeout.raise_if_expired() + context = HarnessAdmissionContext( + request_id=request.context.request_id, + sandbox_id=request.context.sandbox_id, + middleware_name=self._middleware_name, + harness="pi", + harness_version=self._harness_version, + hook=AdmissionHook.PROVIDER_CONTEXT, + schema_version="openshell.pi-provider-context.v1", + provider_target=request.target, + provider_adapter_schema="openai.request.v1", + ) + self._receipt_authority.verify_attestation( + agent_attestation, + subject_hash, + entry_count, + context, + policy_fingerprint=self._policy_fingerprint, + ) + timeout.raise_if_expired() + gate_result = self._request_processor.process(request, timeout=timeout) + timeout.raise_if_expired() + if gate_result.decision is EgressDecision.DENY: + return gate_result + final_request = apply_request_mutations( + request, gate_result.request_mutations + ) + final_entries = extract_provider_entries(final_request, timeout) + if final_entries != entries: + return self._deny("semantic_mutation_denied") + timeout.raise_if_expired() + return gate_result.model_copy( + update={ + "request_mutations": RequestMutations( + replacement_body=gate_result.request_mutations.replacement_body, + header_mutations=( + *gate_result.request_mutations.header_mutations, + RemoveHeaderMutation(kind="remove", name=RECEIPT_HEADER), + ), + ) + } + ) + except ReceiptVerificationError as error: + return self._deny(error.reason_code) + except TimeoutExpiredError: + return self._deny("egress_verification_failed") + except (ProviderShapeError, ValidationError): + return self._deny("provider_shape_unsupported") + except (EgressGateError, GateError, ValueError): + return self._deny("egress_verification_failed") + except Exception: + return self._deny("egress_verification_failed") + + def _deny(self, reason_code: str) -> EgressResult: + return EgressResult( + decision=EgressDecision.DENY, + decision_source=GateDecisionSource( + kind=DecisionSourceKind.GATE, + gate_name="agent-attestation-verifier", + gate_type="agent-attestation-verifier", + ), + reason_code=reason_code, + policy_fingerprint=self._policy_fingerprint, + ) + + +__all__ = [ + "AttestedEgressProcessor", + "HarnessAdmissionProcessor", + "RECEIPT_HEADER", +] diff --git a/projects/egress-gate/src/egress_gate/admission/receipts.py b/projects/egress-gate/src/egress_gate/admission/receipts.py new file mode 100644 index 00000000..74d449a6 --- /dev/null +++ b/projects/egress-gate/src/egress_gate/admission/receipts.py @@ -0,0 +1,232 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Short-lived Ed25519 agent attestations.""" + +from __future__ import annotations + +import base64 +import hashlib +import secrets +from datetime import UTC, datetime +from typing import Literal + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, +) +from pydantic import Field, ValidationError + +from egress_gate.admission.canonical import canonical_json_bytes +from egress_gate.admission.models import ( + AdmissionHook, + AdmissionProvenance, + HarnessAdmissionContext, +) +from egress_gate.base import StrictDomainModel +from egress_gate.string_validators import BoundedMetadataString, ScalarString + + +class AgentAttestationClaimsV2(StrictDomainModel): + """Supervisor-only proof that one complete provider context was admitted.""" + + attestation_version: Literal["agent-attestation.v2"] = "agent-attestation.v2" + canonicalization_version: Literal["canonical-json.v1"] = "canonical-json.v1" + harness: ScalarString + harness_version: Literal["sdk-v1"] + harness_schema: ScalarString + hook: Literal["provider_context"] + middleware_binding: BoundedMetadataString + policy_fingerprint: ScalarString + sandbox_id: BoundedMetadataString + session_id: BoundedMetadataString + submission_id: BoundedMetadataString + attestation_id: str = Field(pattern=r"^[0-9a-f]{32}$") + provider_adapter_schema: Literal["openai.request.v1"] + host: ScalarString + port: int = Field(ge=0, le=2**32 - 1) + subject_kind: Literal["context"] = "context" + subject_hash: str = Field(pattern=r"^[0-9a-f]{64}$") + entry_count: int = Field(ge=1) + issued_at: int = Field(ge=0) + expires_at: int = Field(ge=0) + key_id: str = Field(pattern=r"^[0-9a-f]{16}$") + + +class ReceiptVerificationError(ValueError): + """A bounded receipt verification failure.""" + + def __init__(self, reason_code: str) -> None: + super().__init__(reason_code) + self.reason_code = reason_code + + +class ReceiptAuthority: + """Single-instance Ed25519 issuer and verifier with an ephemeral default key.""" + + def __init__( + self, + private_key: Ed25519PrivateKey | None = None, + *, + allowed_clock_skew_seconds: int = 5, + ) -> None: + if not 0 <= allowed_clock_skew_seconds <= 30: + raise ValueError("attestation clock skew must be between 0 and 30 seconds") + self._private_key = private_key or Ed25519PrivateKey.generate() + self._public_key = self._private_key.public_key() + public_bytes = self._public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + self._key_id = hashlib.sha256(public_bytes).hexdigest()[:16] + self._allowed_clock_skew_seconds = allowed_clock_skew_seconds + self._attestation_lifetime_seconds = 300 + + @property + def key_id(self) -> str: + """Return the non-secret identifier of the active ephemeral key.""" + return self._key_id + + def issue_attestation( + self, + subject_hash: str, + entry_count: int, + context: HarnessAdmissionContext, + provenance: AdmissionProvenance, + *, + policy_fingerprint: str, + now: int | None = None, + ) -> bytes: + """Issue a retry-safe approval receipt for a provider-context projection.""" + if ( + context.harness_version != "sdk-v1" + or context.hook is not AdmissionHook.PROVIDER_CONTEXT + ): + raise ValueError("agent attestation context is unsupported") + issued_at = _now_seconds() if now is None else now + target = context.provider_target + claims = AgentAttestationClaimsV2( + harness=context.harness, + harness_version=context.harness_version, + harness_schema=context.schema_version, + hook=context.hook.value, + middleware_binding=context.middleware_name, + policy_fingerprint=policy_fingerprint, + sandbox_id=context.sandbox_id, + session_id=provenance.session_id, + submission_id=provenance.submission_id, + attestation_id=secrets.token_hex(16), + provider_adapter_schema=context.provider_adapter_schema, + host=target.host, + port=target.port, + subject_hash=subject_hash, + entry_count=entry_count, + issued_at=issued_at, + expires_at=issued_at + self._attestation_lifetime_seconds, + key_id=self._key_id, + ) + payload = canonical_json_bytes(claims) + signature = self._private_key.sign(payload) + return b"ag2." + _encode(payload) + b"." + _encode(signature) + + def verify_attestation( + self, + attestation: bytes, + subject_hash: str, + entry_count: int, + context: HarnessAdmissionContext, + *, + policy_fingerprint: str, + now: int | None = None, + ) -> AgentAttestationClaimsV2: + """Verify a receipt against authoritative context and intercepted content.""" + payload, signature = _decode_token( + attestation, prefix=b"ag2", malformed_reason="attestation_malformed" + ) + try: + self._public_key.verify(signature, payload) + except InvalidSignature: + raise ReceiptVerificationError("attestation_signature_invalid") from None + try: + claims = AgentAttestationClaimsV2.model_validate_json(payload, strict=True) + except ValidationError: + raise ReceiptVerificationError("attestation_malformed") from None + if canonical_json_bytes(claims) != payload: + raise ReceiptVerificationError("attestation_malformed") + current = _now_seconds() if now is None else now + if claims.key_id != self._key_id: + raise ReceiptVerificationError("attestation_key_mismatch") + if claims.issued_at > current + self._allowed_clock_skew_seconds: + raise ReceiptVerificationError("attestation_not_yet_valid") + if claims.expires_at <= current or claims.expires_at <= claims.issued_at: + raise ReceiptVerificationError("attestation_expired") + target = context.provider_target + expected = ( + context.harness, + context.harness_version, + context.schema_version, + context.hook.value, + context.middleware_name, + policy_fingerprint, + context.sandbox_id, + context.provider_adapter_schema, + target.host, + target.port, + ) + actual = ( + claims.harness, + claims.harness_version, + claims.harness_schema, + claims.hook, + claims.middleware_binding, + claims.policy_fingerprint, + claims.sandbox_id, + claims.provider_adapter_schema, + claims.host, + claims.port, + ) + if actual != expected: + raise ReceiptVerificationError("attestation_context_mismatch") + if claims.entry_count != entry_count: + raise ReceiptVerificationError("entry_count_mismatch") + if claims.subject_hash != subject_hash: + raise ReceiptVerificationError("context_hash_mismatch") + return claims + + +def _encode(value: bytes) -> bytes: + return base64.urlsafe_b64encode(value).rstrip(b"=") + + +def _decode(value: bytes) -> bytes: + padding = b"=" * (-len(value) % 4) + try: + return base64.b64decode(value + padding, altchars=b"-_", validate=True) + except ValueError: + raise ValueError("token is malformed") from None + + +def _decode_token( + value: bytes, *, prefix: bytes, malformed_reason: str +) -> tuple[bytes, bytes]: + if len(value) > 8 * 1024: + raise ReceiptVerificationError(malformed_reason) + parts = value.split(b".") + if len(parts) != 3 or parts[0] != prefix or not parts[1] or not parts[2]: + raise ReceiptVerificationError(malformed_reason) + try: + return _decode(parts[1]), _decode(parts[2]) + except ValueError: + raise ReceiptVerificationError(malformed_reason) from None + + +def _now_seconds() -> int: + return int(datetime.now(UTC).timestamp()) + + +__all__ = [ + "AgentAttestationClaimsV2", + "ReceiptAuthority", + "ReceiptVerificationError", +] diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py index c254b0f3..20b178a6 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py @@ -26,53 +26,81 @@ from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"y\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\"\xca\x01\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x16\n\x0emax_body_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xd6\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"w\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\x82\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01*y\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xcd\x02\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResultb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x94\x01\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\x12\x19\n\x11\x65xpected_audience\x18\x04 \x01(\t\"\xcd\x01\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x19\n\x11max_payload_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xd6\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\xae\x02\n\x15WebSocketSessionEvent\x12@\n\tpreflight\x18\x01 \x01(\x0b\x32+.openshell.middleware.v1.WebSocketPreflightH\x00\x12G\n\rsession_start\x18\x02 \x01(\x0b\x32..openshell.middleware.v1.WebSocketSessionStartH\x00\x12<\n\x07message\x18\x03 \x01(\x0b\x32).openshell.middleware.v1.WebSocketMessageH\x00\x12\x43\n\x0bsession_end\x18\x04 \x01(\x0b\x32,.openshell.middleware.v1.WebSocketSessionEndH\x00\x42\x07\n\x05\x65vent\"\xc3\x02\n\x12WebSocketPreflight\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x03 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x1e\n\x16requested_subprotocols\x18\x05 \x03(\t\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\'\n\x06\x63onfig\x18\x07 \x01(\x0b\x32\x17.google.protobuf.Struct\"5\n\x15WebSocketSessionStart\x12\x1c\n\x14selected_subprotocol\x18\x01 \x01(\t\"Q\n\x10WebSocketMessage\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x0e\n\x04text\x18\x02 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x03 \x01(\x0cH\x00\x42\t\n\x07payload\"Y\n\x13WebSocketSessionEnd\x12\x42\n\x06reason\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.WebSocketSessionEndReason\"\xbe\x02\n\x1aWebSocketPreflightDecision\x12\x41\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x31.openshell.middleware.v1.WebSocketPreflightAction\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0breason_code\x18\x03 \x01(\t\x12\x32\n\x08\x66indings\x18\x04 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12S\n\x08metadata\x18\x05 \x03(\x0b\x32\x41.openshell.middleware.v1.WebSocketPreflightDecision.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xeb\x02\n\x16WebSocketMessageResult\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x33\n\x08\x64\x65\x63ision\x18\x02 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x04text\x18\x03 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x04 \x01(\x0cH\x00\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x13\n\x0breason_code\x18\x06 \x01(\t\x12\x32\n\x08\x66indings\x18\x07 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12O\n\x08metadata\x18\x08 \x03(\x0b\x32=.openshell.middleware.v1.WebSocketMessageResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\r\n\x0breplacement\"\xc5\x01\n\x1bWebSocketSessionEventResult\x12Q\n\x12preflight_decision\x18\x01 \x01(\x0b\x32\x33.openshell.middleware.v1.WebSocketPreflightDecisionH\x00\x12I\n\x0emessage_result\x18\x02 \x01(\x0b\x32/.openshell.middleware.v1.WebSocketMessageResultH\x00\x42\x08\n\x06result\"\xa0\x01\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\x12\x14\n\x0csandbox_name\x18\x04 \x01(\t\x12\x11\n\tworkspace\x18\x05 \x01(\t\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xb9\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01\x12\x35\n1SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE\x10\x02*\xa5\x01\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01\x12*\n&SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN\x10\x02*\xc2\x04\n\x19WebSocketSessionEndReason\x12-\n)WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED\x10\x00\x12.\n*WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE\x10\x01\x12\x31\n-WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT\x10\x02\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD\x10\x03\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL\x10\x04\x12\x34\n0WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE\x10\x05\x12\x30\n,WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR\x10\x06\x12.\n*WEB_SOCKET_SESSION_END_REASON_CANCELLATION\x10\x07\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED\x10\x08\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL\x10\t\x12/\n+WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED\x10\n*\xbc\x01\n\x18WebSocketPreflightAction\x12+\n\'WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED\x10\x00\x12\'\n#WEB_SOCKET_PREFLIGHT_ACTION_INSPECT\x10\x01\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_SKIP\x10\x02\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_DENY\x10\x03*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xd4\x03\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResult\x12\x84\x01\n\x18\x45valuateWebSocketSession\x12..openshell.middleware.v1.WebSocketSessionEvent\x1a\x34.openshell.middleware.v1.WebSocketSessionEventResult(\x01\x30\x01\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'supervisor_middleware_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._loaded_options = None + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_options = b'8\001' + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._loaded_options = None + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_options = b'8\001' _globals['_HTTPREQUESTRESULT_METADATAENTRY']._loaded_options = None _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_options = b'8\001' - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=2037 - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=2167 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=2169 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=2290 - _globals['_DECISION']._serialized_start=2292 - _globals['_DECISION']._serialized_end=2367 - _globals['_EXISTINGHEADERACTION']._serialized_start=2370 - _globals['_EXISTINGHEADERACTION']._serialized_end=2538 - _globals['_MIDDLEWAREMANIFEST']._serialized_start=115 - _globals['_MIDDLEWAREMANIFEST']._serialized_end=236 - _globals['_MIDDLEWAREBINDING']._serialized_start=239 - _globals['_MIDDLEWAREBINDING']._serialized_end=441 - _globals['_VALIDATECONFIGREQUEST']._serialized_start=443 - _globals['_VALIDATECONFIGREQUEST']._serialized_end=532 - _globals['_VALIDATECONFIGRESPONSE']._serialized_start=534 - _globals['_VALIDATECONFIGRESPONSE']._serialized_end=589 - _globals['_HTTPREQUESTEVALUATION']._serialized_start=592 - _globals['_HTTPREQUESTEVALUATION']._serialized_end=934 - _globals['_HTTPHEADER']._serialized_start=936 - _globals['_HTTPHEADER']._serialized_end=977 - _globals['_REQUESTCONTEXT']._serialized_start=979 - _globals['_REQUESTCONTEXT']._serialized_end=1098 - _globals['_HTTPREQUESTTARGET']._serialized_start=1100 - _globals['_HTTPREQUESTTARGET']._serialized_end=1208 - _globals['_PROCESS']._serialized_start=1210 - _globals['_PROCESS']._serialized_end=1267 - _globals['_FINDING']._serialized_start=1269 - _globals['_FINDING']._serialized_end=1360 - _globals['_WRITEHEADER']._serialized_start=1362 - _globals['_WRITEHEADER']._serialized_end=1472 - _globals['_REMOVEHEADER']._serialized_start=1474 - _globals['_REMOVEHEADER']._serialized_end=1502 - _globals['_HEADERMUTATION']._serialized_start=1505 - _globals['_HEADERMUTATION']._serialized_end=1646 - _globals['_HTTPREQUESTRESULT']._serialized_start=1649 - _globals['_HTTPREQUESTRESULT']._serialized_end=2034 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=1987 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2034 - _globals['_SUPERVISORMIDDLEWARE']._serialized_start=2541 - _globals['_SUPERVISORMIDDLEWARE']._serialized_end=2874 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=3857 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=4042 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=4045 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=4210 + _globals['_WEBSOCKETSESSIONENDREASON']._serialized_start=4213 + _globals['_WEBSOCKETSESSIONENDREASON']._serialized_end=4791 + _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_start=4794 + _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_end=4982 + _globals['_DECISION']._serialized_start=4984 + _globals['_DECISION']._serialized_end=5059 + _globals['_EXISTINGHEADERACTION']._serialized_start=5062 + _globals['_EXISTINGHEADERACTION']._serialized_end=5230 + _globals['_MIDDLEWAREMANIFEST']._serialized_start=116 + _globals['_MIDDLEWAREMANIFEST']._serialized_end=264 + _globals['_MIDDLEWAREBINDING']._serialized_start=267 + _globals['_MIDDLEWAREBINDING']._serialized_end=472 + _globals['_VALIDATECONFIGREQUEST']._serialized_start=474 + _globals['_VALIDATECONFIGREQUEST']._serialized_end=563 + _globals['_VALIDATECONFIGRESPONSE']._serialized_start=565 + _globals['_VALIDATECONFIGRESPONSE']._serialized_end=620 + _globals['_HTTPREQUESTEVALUATION']._serialized_start=623 + _globals['_HTTPREQUESTEVALUATION']._serialized_end=965 + _globals['_HTTPHEADER']._serialized_start=967 + _globals['_HTTPHEADER']._serialized_end=1008 + _globals['_WEBSOCKETSESSIONEVENT']._serialized_start=1011 + _globals['_WEBSOCKETSESSIONEVENT']._serialized_end=1313 + _globals['_WEBSOCKETPREFLIGHT']._serialized_start=1316 + _globals['_WEBSOCKETPREFLIGHT']._serialized_end=1639 + _globals['_WEBSOCKETSESSIONSTART']._serialized_start=1641 + _globals['_WEBSOCKETSESSIONSTART']._serialized_end=1694 + _globals['_WEBSOCKETMESSAGE']._serialized_start=1696 + _globals['_WEBSOCKETMESSAGE']._serialized_end=1777 + _globals['_WEBSOCKETSESSIONEND']._serialized_start=1779 + _globals['_WEBSOCKETSESSIONEND']._serialized_end=1868 + _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_start=1871 + _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_end=2189 + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_start=2142 + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_end=2189 + _globals['_WEBSOCKETMESSAGERESULT']._serialized_start=2192 + _globals['_WEBSOCKETMESSAGERESULT']._serialized_end=2555 + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_start=2142 + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_end=2189 + _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_start=2558 + _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_end=2755 + _globals['_REQUESTCONTEXT']._serialized_start=2758 + _globals['_REQUESTCONTEXT']._serialized_end=2918 + _globals['_HTTPREQUESTTARGET']._serialized_start=2920 + _globals['_HTTPREQUESTTARGET']._serialized_end=3028 + _globals['_PROCESS']._serialized_start=3030 + _globals['_PROCESS']._serialized_end=3087 + _globals['_FINDING']._serialized_start=3089 + _globals['_FINDING']._serialized_end=3180 + _globals['_WRITEHEADER']._serialized_start=3182 + _globals['_WRITEHEADER']._serialized_end=3292 + _globals['_REMOVEHEADER']._serialized_start=3294 + _globals['_REMOVEHEADER']._serialized_end=3322 + _globals['_HEADERMUTATION']._serialized_start=3325 + _globals['_HEADERMUTATION']._serialized_end=3466 + _globals['_HTTPREQUESTRESULT']._serialized_start=3469 + _globals['_HTTPREQUESTRESULT']._serialized_end=3854 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=2142 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2189 + _globals['_SUPERVISORMIDDLEWARE']._serialized_start=5233 + _globals['_SUPERVISORMIDDLEWARE']._serialized_end=5701 # @@protoc_insertion_point(module_scope) diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi index 10eac7f5..e7cae72e 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi @@ -13,11 +13,34 @@ class SupervisorMiddlewareOperation(int, metaclass=_enum_type_wrapper.EnumTypeWr __slots__ = () SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED: _ClassVar[SupervisorMiddlewareOperation] SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST: _ClassVar[SupervisorMiddlewareOperation] + SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE: _ClassVar[SupervisorMiddlewareOperation] class SupervisorMiddlewarePhase(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED: _ClassVar[SupervisorMiddlewarePhase] SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: _ClassVar[SupervisorMiddlewarePhase] + SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN: _ClassVar[SupervisorMiddlewarePhase] + +class WebSocketSessionEndReason(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_CANCELLATION: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED: _ClassVar[WebSocketSessionEndReason] + +class WebSocketPreflightAction(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED: _ClassVar[WebSocketPreflightAction] + WEB_SOCKET_PREFLIGHT_ACTION_INSPECT: _ClassVar[WebSocketPreflightAction] + WEB_SOCKET_PREFLIGHT_ACTION_SKIP: _ClassVar[WebSocketPreflightAction] + WEB_SOCKET_PREFLIGHT_ACTION_DENY: _ClassVar[WebSocketPreflightAction] class Decision(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () @@ -33,8 +56,25 @@ class ExistingHeaderAction(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): EXISTING_HEADER_ACTION_SKIP: _ClassVar[ExistingHeaderAction] SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST: SupervisorMiddlewareOperation +SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED: SupervisorMiddlewarePhase SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: SupervisorMiddlewarePhase +SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN: SupervisorMiddlewarePhase +WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_CANCELLATION: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED: WebSocketSessionEndReason +WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED: WebSocketPreflightAction +WEB_SOCKET_PREFLIGHT_ACTION_INSPECT: WebSocketPreflightAction +WEB_SOCKET_PREFLIGHT_ACTION_SKIP: WebSocketPreflightAction +WEB_SOCKET_PREFLIGHT_ACTION_DENY: WebSocketPreflightAction DECISION_UNSPECIFIED: Decision DECISION_ALLOW: Decision DECISION_DENY: Decision @@ -44,26 +84,28 @@ EXISTING_HEADER_ACTION_OVERWRITE: ExistingHeaderAction EXISTING_HEADER_ACTION_SKIP: ExistingHeaderAction class MiddlewareManifest(_message.Message): - __slots__ = ("name", "service_version", "bindings") + __slots__ = ("name", "service_version", "bindings", "expected_audience") NAME_FIELD_NUMBER: _ClassVar[int] SERVICE_VERSION_FIELD_NUMBER: _ClassVar[int] BINDINGS_FIELD_NUMBER: _ClassVar[int] + EXPECTED_AUDIENCE_FIELD_NUMBER: _ClassVar[int] name: str service_version: str bindings: _containers.RepeatedCompositeFieldContainer[MiddlewareBinding] - def __init__(self, name: _Optional[str] = ..., service_version: _Optional[str] = ..., bindings: _Optional[_Iterable[_Union[MiddlewareBinding, _Mapping]]] = ...) -> None: ... + expected_audience: str + def __init__(self, name: _Optional[str] = ..., service_version: _Optional[str] = ..., bindings: _Optional[_Iterable[_Union[MiddlewareBinding, _Mapping]]] = ..., expected_audience: _Optional[str] = ...) -> None: ... class MiddlewareBinding(_message.Message): - __slots__ = ("operation", "phase", "max_body_bytes", "timeout") + __slots__ = ("operation", "phase", "max_payload_bytes", "timeout") OPERATION_FIELD_NUMBER: _ClassVar[int] PHASE_FIELD_NUMBER: _ClassVar[int] - MAX_BODY_BYTES_FIELD_NUMBER: _ClassVar[int] + MAX_PAYLOAD_BYTES_FIELD_NUMBER: _ClassVar[int] TIMEOUT_FIELD_NUMBER: _ClassVar[int] operation: SupervisorMiddlewareOperation phase: SupervisorMiddlewarePhase - max_body_bytes: int + max_payload_bytes: int timeout: str - def __init__(self, operation: _Optional[_Union[SupervisorMiddlewareOperation, str]] = ..., phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., max_body_bytes: _Optional[int] = ..., timeout: _Optional[str] = ...) -> None: ... + def __init__(self, operation: _Optional[_Union[SupervisorMiddlewareOperation, str]] = ..., phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., max_payload_bytes: _Optional[int] = ..., timeout: _Optional[str] = ...) -> None: ... class ValidateConfigRequest(_message.Message): __slots__ = ("config", "middleware_name") @@ -107,15 +149,127 @@ class HttpHeader(_message.Message): value: str def __init__(self, name: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... +class WebSocketSessionEvent(_message.Message): + __slots__ = ("preflight", "session_start", "message", "session_end") + PREFLIGHT_FIELD_NUMBER: _ClassVar[int] + SESSION_START_FIELD_NUMBER: _ClassVar[int] + MESSAGE_FIELD_NUMBER: _ClassVar[int] + SESSION_END_FIELD_NUMBER: _ClassVar[int] + preflight: WebSocketPreflight + session_start: WebSocketSessionStart + message: WebSocketMessage + session_end: WebSocketSessionEnd + def __init__(self, preflight: _Optional[_Union[WebSocketPreflight, _Mapping]] = ..., session_start: _Optional[_Union[WebSocketSessionStart, _Mapping]] = ..., message: _Optional[_Union[WebSocketMessage, _Mapping]] = ..., session_end: _Optional[_Union[WebSocketSessionEnd, _Mapping]] = ...) -> None: ... + +class WebSocketPreflight(_message.Message): + __slots__ = ("session_id", "phase", "context", "target", "requested_subprotocols", "middleware_name", "config") + SESSION_ID_FIELD_NUMBER: _ClassVar[int] + PHASE_FIELD_NUMBER: _ClassVar[int] + CONTEXT_FIELD_NUMBER: _ClassVar[int] + TARGET_FIELD_NUMBER: _ClassVar[int] + REQUESTED_SUBPROTOCOLS_FIELD_NUMBER: _ClassVar[int] + MIDDLEWARE_NAME_FIELD_NUMBER: _ClassVar[int] + CONFIG_FIELD_NUMBER: _ClassVar[int] + session_id: str + phase: SupervisorMiddlewarePhase + context: RequestContext + target: HttpRequestTarget + requested_subprotocols: _containers.RepeatedScalarFieldContainer[str] + middleware_name: str + config: _struct_pb2.Struct + def __init__(self, session_id: _Optional[str] = ..., phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., target: _Optional[_Union[HttpRequestTarget, _Mapping]] = ..., requested_subprotocols: _Optional[_Iterable[str]] = ..., middleware_name: _Optional[str] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ... + +class WebSocketSessionStart(_message.Message): + __slots__ = ("selected_subprotocol",) + SELECTED_SUBPROTOCOL_FIELD_NUMBER: _ClassVar[int] + selected_subprotocol: str + def __init__(self, selected_subprotocol: _Optional[str] = ...) -> None: ... + +class WebSocketMessage(_message.Message): + __slots__ = ("sequence", "text", "binary") + SEQUENCE_FIELD_NUMBER: _ClassVar[int] + TEXT_FIELD_NUMBER: _ClassVar[int] + BINARY_FIELD_NUMBER: _ClassVar[int] + sequence: int + text: str + binary: bytes + def __init__(self, sequence: _Optional[int] = ..., text: _Optional[str] = ..., binary: _Optional[bytes] = ...) -> None: ... + +class WebSocketSessionEnd(_message.Message): + __slots__ = ("reason",) + REASON_FIELD_NUMBER: _ClassVar[int] + reason: WebSocketSessionEndReason + def __init__(self, reason: _Optional[_Union[WebSocketSessionEndReason, str]] = ...) -> None: ... + +class WebSocketPreflightDecision(_message.Message): + __slots__ = ("action", "reason", "reason_code", "findings", "metadata") + class MetadataEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + ACTION_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + REASON_CODE_FIELD_NUMBER: _ClassVar[int] + FINDINGS_FIELD_NUMBER: _ClassVar[int] + METADATA_FIELD_NUMBER: _ClassVar[int] + action: WebSocketPreflightAction + reason: str + reason_code: str + findings: _containers.RepeatedCompositeFieldContainer[Finding] + metadata: _containers.ScalarMap[str, str] + def __init__(self, action: _Optional[_Union[WebSocketPreflightAction, str]] = ..., reason: _Optional[str] = ..., reason_code: _Optional[str] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ...) -> None: ... + +class WebSocketMessageResult(_message.Message): + __slots__ = ("sequence", "decision", "text", "binary", "reason", "reason_code", "findings", "metadata") + class MetadataEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + SEQUENCE_FIELD_NUMBER: _ClassVar[int] + DECISION_FIELD_NUMBER: _ClassVar[int] + TEXT_FIELD_NUMBER: _ClassVar[int] + BINARY_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + REASON_CODE_FIELD_NUMBER: _ClassVar[int] + FINDINGS_FIELD_NUMBER: _ClassVar[int] + METADATA_FIELD_NUMBER: _ClassVar[int] + sequence: int + decision: Decision + text: str + binary: bytes + reason: str + reason_code: str + findings: _containers.RepeatedCompositeFieldContainer[Finding] + metadata: _containers.ScalarMap[str, str] + def __init__(self, sequence: _Optional[int] = ..., decision: _Optional[_Union[Decision, str]] = ..., text: _Optional[str] = ..., binary: _Optional[bytes] = ..., reason: _Optional[str] = ..., reason_code: _Optional[str] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ...) -> None: ... + +class WebSocketSessionEventResult(_message.Message): + __slots__ = ("preflight_decision", "message_result") + PREFLIGHT_DECISION_FIELD_NUMBER: _ClassVar[int] + MESSAGE_RESULT_FIELD_NUMBER: _ClassVar[int] + preflight_decision: WebSocketPreflightDecision + message_result: WebSocketMessageResult + def __init__(self, preflight_decision: _Optional[_Union[WebSocketPreflightDecision, _Mapping]] = ..., message_result: _Optional[_Union[WebSocketMessageResult, _Mapping]] = ...) -> None: ... + class RequestContext(_message.Message): - __slots__ = ("request_id", "sandbox_id", "originating_process") + __slots__ = ("request_id", "sandbox_id", "originating_process", "sandbox_name", "workspace") REQUEST_ID_FIELD_NUMBER: _ClassVar[int] SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] ORIGINATING_PROCESS_FIELD_NUMBER: _ClassVar[int] + SANDBOX_NAME_FIELD_NUMBER: _ClassVar[int] + WORKSPACE_FIELD_NUMBER: _ClassVar[int] request_id: str sandbox_id: str originating_process: Process - def __init__(self, request_id: _Optional[str] = ..., sandbox_id: _Optional[str] = ..., originating_process: _Optional[_Union[Process, _Mapping]] = ...) -> None: ... + sandbox_name: str + workspace: str + def __init__(self, request_id: _Optional[str] = ..., sandbox_id: _Optional[str] = ..., originating_process: _Optional[_Union[Process, _Mapping]] = ..., sandbox_name: _Optional[str] = ..., workspace: _Optional[str] = ...) -> None: ... class HttpRequestTarget(_message.Message): __slots__ = ("scheme", "host", "port", "method", "path", "query") diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py index a4914b37..1c44750a 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py @@ -28,7 +28,8 @@ class SupervisorMiddlewareStub: """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP egress before OpenShell injects credentials. + sandbox HTTP requests and client WebSocket text messages before OpenShell + injects credentials. """ def __init__(self, channel): @@ -52,11 +53,17 @@ def __init__(self, channel): request_serializer=supervisor__middleware__pb2.HttpRequestEvaluation.SerializeToString, response_deserializer=supervisor__middleware__pb2.HttpRequestResult.FromString, _registered_method=True) + self.EvaluateWebSocketSession = channel.stream_stream( + '/openshell.middleware.v1.SupervisorMiddleware/EvaluateWebSocketSession', + request_serializer=supervisor__middleware__pb2.WebSocketSessionEvent.SerializeToString, + response_deserializer=supervisor__middleware__pb2.WebSocketSessionEventResult.FromString, + _registered_method=True) class SupervisorMiddlewareServicer: """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP egress before OpenShell injects credentials. + sandbox HTTP requests and client WebSocket text messages before OpenShell + injects credentials. """ def Describe(self, request, context): @@ -81,6 +88,19 @@ def EvaluateHttpRequest(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def EvaluateWebSocketSession(self, request_iterator, context): + """EvaluateWebSocketSession opens one ordered, phase-specific stream for a + single middleware stage and WebSocket upgrade attempt. The current + implementation supports client-to-upstream text messages at + PRE_CREDENTIALS; PRE_RETURN is reserved for upstream-to-client messages. + A request may go unanswered when the session terminates. For every opened + stage stream, OpenShell attempts at most one session_end before closing the + stream when its transport is still writable. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def add_SupervisorMiddlewareServicer_to_server(servicer, server): rpc_method_handlers = { @@ -99,6 +119,11 @@ def add_SupervisorMiddlewareServicer_to_server(servicer, server): request_deserializer=supervisor__middleware__pb2.HttpRequestEvaluation.FromString, response_serializer=supervisor__middleware__pb2.HttpRequestResult.SerializeToString, ), + 'EvaluateWebSocketSession': grpc.stream_stream_rpc_method_handler( + servicer.EvaluateWebSocketSession, + request_deserializer=supervisor__middleware__pb2.WebSocketSessionEvent.FromString, + response_serializer=supervisor__middleware__pb2.WebSocketSessionEventResult.SerializeToString, + ), } generic_handler = grpc.method_handlers_generic_handler( 'openshell.middleware.v1.SupervisorMiddleware', rpc_method_handlers) @@ -109,7 +134,8 @@ def add_SupervisorMiddlewareServicer_to_server(servicer, server): # This class is part of an EXPERIMENTAL API. class SupervisorMiddleware: """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP egress before OpenShell injects credentials. + sandbox HTTP requests and client WebSocket text messages before OpenShell + injects credentials. """ @staticmethod @@ -192,3 +218,30 @@ def EvaluateHttpRequest(request, timeout, metadata, _registered_method=True) + + @staticmethod + def EvaluateWebSocketSession(request_iterator, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.stream_stream( + request_iterator, + target, + '/openshell.middleware.v1.SupervisorMiddleware/EvaluateWebSocketSession', + supervisor__middleware__pb2.WebSocketSessionEvent.SerializeToString, + supervisor__middleware__pb2.WebSocketSessionEventResult.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/projects/egress-gate/src/egress_gate/cli.py b/projects/egress-gate/src/egress_gate/cli.py index 6f789c29..02b3a688 100644 --- a/projects/egress-gate/src/egress_gate/cli.py +++ b/projects/egress-gate/src/egress_gate/cli.py @@ -167,9 +167,20 @@ def serve( ), ), ] = f"{DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING:g}s", + admission_config: Annotated[ + Path | None, + typer.Option( + "--admission-config", + help=( + "Operator-owned JSON configuration for authenticated admission " + "and receipt-required egress." + ), + ), + ] = None, ) -> None: """Start the Egress Gate gRPC service and run until shutdown.""" options = _command_options(context) + from egress_gate.service.admission import AdmissionServerConfig from egress_gate.service.server import EgressGateServer try: @@ -206,13 +217,21 @@ def serve( remembered.config_path, ) try: + admission = ( + AdmissionServerConfig.model_validate_json(admission_config.read_bytes()) + if admission_config is not None + else None + ) EgressGateServer( options.registry, timeout_middleware_processing=timeout_middleware_processing, + admission=admission, ).serve_sync(listen) except EgressGateError as error: _render_egress_error("Egress Gate could not start", error) raise typer.Exit(code=1) from None + except (ValueError, OSError): + raise typer.BadParameter("Invalid admission service configuration") from None @app.command( diff --git a/projects/egress-gate/src/egress_gate/constants.py b/projects/egress-gate/src/egress_gate/constants.py index a854ef9b..3dde4144 100644 --- a/projects/egress-gate/src/egress_gate/constants.py +++ b/projects/egress-gate/src/egress_gate/constants.py @@ -79,6 +79,7 @@ MAX_PROTO_TARGET_BYTES = 32 * 1024 MAX_PROTO_HEADERS = 128 MAX_PROTO_HEADERS_BYTES = 64 * 1024 +MAX_AGENT_ATTESTATION_BYTES = 8 * 1024 PROTOBUF_ENVELOPE_ALLOWANCE_BYTES = 1024 * 1024 MAX_RECEIVE_MESSAGE_BYTES = MAX_BODY_BYTES + PROTOBUF_ENVELOPE_ALLOWANCE_BYTES diff --git a/projects/egress-gate/src/egress_gate/request_processor.py b/projects/egress-gate/src/egress_gate/request_processor.py index 0b94dadb..e19d3fe6 100644 --- a/projects/egress-gate/src/egress_gate/request_processor.py +++ b/projects/egress-gate/src/egress_gate/request_processor.py @@ -92,6 +92,11 @@ def __init__( self._gates = gates self._policy_fingerprint = policy_fingerprint + @property + def policy_fingerprint(self) -> str | None: + """Return the immutable fingerprint of the prepared policy.""" + return self._policy_fingerprint + def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: """Evaluate one request and return an atomic final domain result.""" if not isinstance(request, HttpRequest): diff --git a/projects/egress-gate/src/egress_gate/service/admission.py b/projects/egress-gate/src/egress_gate/service/admission.py new file mode 100644 index 00000000..3edca279 --- /dev/null +++ b/projects/egress-gate/src/egress_gate/service/admission.py @@ -0,0 +1,137 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Authenticated HTTP admission beside the standard OpenShell middleware RPCs.""" + +from __future__ import annotations + +import base64 +import hmac +import json +import ssl +from pathlib import Path +from uuid import uuid4 + +from aiohttp import web +from pydantic import SecretStr, ValidationError + +from egress_gate.admission import ( + MAX_ADMISSION_BODY_BYTES, + AdmissionHook, + AdmissionProvenance, + HarnessAdmissionContext, + HarnessAdmissionRequest, +) +from egress_gate.base import StrictDomainModel +from egress_gate.errors import EgressGateError, TimeoutExpiredError +from egress_gate.request import HttpTarget +from egress_gate.service.servicer import EgressGateMiddleware +from egress_gate.string_validators import BoundedMetadataString + + +class AdmissionServerConfig(StrictDomainModel): + """Operator-owned configuration; never supplied by the sandbox application.""" + + listen: str + tls_certificate: Path + tls_private_key: Path + gateway_public_key: Path + gateway_issuer: str + gateway_audience: str + middleware_name: BoundedMetadataString + bearer_token: SecretStr + sandbox_id_file: Path + provider_target: HttpTarget + policy: dict[str, object] + + +class AdmissionCall(StrictDomainModel): + """Only the candidate and correlation identifiers are caller assertions.""" + + kind: AdmissionHook + session_id: BoundedMetadataString + submission_id: BoundedMetadataString + body: dict[str, object] + + +def create_admission_application( + middleware: EgressGateMiddleware, config: AdmissionServerConfig +) -> web.Application: + """Create one bounded endpoint; identity and policy come from the operator.""" + + async def admit(request: web.Request) -> web.Response: + authorizations = request.headers.getall("Authorization", []) + expected = f"Bearer {config.bearer_token.get_secret_value()}".encode() + if len(authorizations) != 1 or not hmac.compare_digest( + authorizations[0].encode(), expected + ): + raise web.HTTPUnauthorized(text="admission authentication failed") + try: + call = AdmissionCall.model_validate_json(await request.read()) + schema = call.body.get("schema_version") + if not isinstance(schema, str): + raise ValueError("missing schema") + # Setup learns the real ID after sandbox creation. Reading this small, + # host-owned file lets setup finish without an administrative HTTP API. + # Until it exists, admission is unavailable, never anonymously allowed. + sandbox_id = config.sandbox_id_file.read_text().strip() + context = HarnessAdmissionContext( + request_id=str(uuid4()), + sandbox_id=sandbox_id, + middleware_name=config.middleware_name, + harness="pi", + harness_version="sdk-v1", + hook=call.kind, + schema_version=schema, + provider_target=config.provider_target, + provider_adapter_schema="openai.request.v1", + ) + result = await middleware.admit( + HarnessAdmissionRequest( + request_body=json.dumps( + call.body, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode(), + provenance=AdmissionProvenance( + session_id=call.session_id, submission_id=call.submission_id + ), + ), + context, + config.policy, + ) + except (ValidationError, ValueError): + raise web.HTTPBadRequest(text="invalid admission request") from None + except (OSError, EgressGateError, TimeoutExpiredError): + raise web.HTTPServiceUnavailable( + text="admission is not provisioned" + ) from None + return web.json_response( + { + "decision": result.decision.value, + "reason_code": result.reason_code, + "replacement": ( + json.loads(result.replacement_body) + if result.replacement_body is not None + else None + ), + "receipt": ( + base64.urlsafe_b64encode(result.attestation).decode("ascii") + if result.attestation is not None + else None + ), + } + ) + + application = web.Application(client_max_size=MAX_ADMISSION_BODY_BYTES) + application.router.add_post("/v1/admission", admit) + return application + + +def admission_tls_context(config: AdmissionServerConfig) -> ssl.SSLContext: + """Use operator-provisioned TLS, with no insecure fallback.""" + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(config.tls_certificate, config.tls_private_key) + return context diff --git a/projects/egress-gate/src/egress_gate/service/authentication.py b/projects/egress-gate/src/egress_gate/service/authentication.py new file mode 100644 index 00000000..844d6952 --- /dev/null +++ b/projects/egress-gate/src/egress_gate/service/authentication.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Verify the upstream OpenShell extension-token contract at the RPC boundary.""" + +from __future__ import annotations + +from collections.abc import Iterable + +import jwt + +from egress_gate.bindings import supervisor_middleware_pb2 as pb2 + + +class GatewayAuthentication: + """A single operator-provisioned gateway key, issuer, and exact audience.""" + + def __init__(self, public_key: bytes, issuer: str, audience: str) -> None: + self._public_key = public_key + self._issuer = issuer + self.audience = audience + + def verify( + self, metadata: Iterable[tuple[str, str | bytes]], request: object + ) -> None: + """Raise on unauthenticated calls or a forged supervisor request context.""" + values = [value for key, value in metadata if key == "authorization"] + if len(values) != 1 or not isinstance(values[0], str): + raise ValueError("authentication required") + scheme, separator, token = values[0].partition(" ") + if scheme != "Bearer" or not separator: + raise ValueError("authentication required") + if jwt.get_unverified_header(token).get("typ") != "openshell-ext+jwt": + raise ValueError("incorrect token type") + claims = jwt.decode( + token, + self._public_key, + algorithms=["EdDSA"], + issuer=self._issuer, + audience=self.audience, + options={"require": ["iss", "aud", "exp", "iat", "caller_kind"]}, + ) + kind = claims["caller_kind"] + if kind not in ("gateway", "supervisor"): + raise ValueError("invalid caller") + if isinstance(request, pb2.HttpRequestEvaluation) and ( + kind != "supervisor" + or not request.context.sandbox_id + or claims.get("sandbox_id") != request.context.sandbox_id + ): + raise ValueError("sandbox identity mismatch") diff --git a/projects/egress-gate/src/egress_gate/service/server.py b/projects/egress-gate/src/egress_gate/service/server.py index 146838d6..00210ea5 100644 --- a/projects/egress-gate/src/egress_gate/service/server.py +++ b/projects/egress-gate/src/egress_gate/service/server.py @@ -10,6 +10,8 @@ from typing import Protocol, runtime_checkable import grpc +import jwt +from aiohttp import web from google.protobuf.message import DecodeError from egress_gate.bindings import supervisor_middleware_pb2_grpc as pb2_grpc @@ -21,6 +23,12 @@ from egress_gate.errors import EgressGateError, ErrorCode from egress_gate.gates.registry import GateRegistry from egress_gate.logging import get_logger +from egress_gate.service.admission import ( + AdmissionServerConfig, + admission_tls_context, + create_admission_application, +) +from egress_gate.service.authentication import GatewayAuthentication from egress_gate.service.servicer import EgressGateMiddleware DEFAULT_LISTEN_ADDRESS = "127.0.0.1:50051" @@ -34,10 +42,23 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float = DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, + admission: AdmissionServerConfig | None = None, ) -> None: + self._admission = admission + self._authentication = ( + GatewayAuthentication( + admission.gateway_public_key.read_bytes(), + admission.gateway_issuer, + admission.gateway_audience, + ) + if admission is not None + else None + ) self._middleware = EgressGateMiddleware( registry, timeout_middleware_processing=timeout_middleware_processing, + require_agent_attestation=admission is not None, + expected_audience=admission.gateway_audience if admission else "", ) def serve_sync(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: @@ -49,11 +70,39 @@ def serve_sync(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: async def serve_async(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: """Serve asynchronously until termination, then close owned resources.""" - server = _create_grpc_server(self._middleware) + server = _create_grpc_server(self._middleware, self._authentication) + runner: web.AppRunner | None = None try: try: requested_port = _validated_listen_port(listen) - bound_port = server.add_insecure_port(listen) + if self._admission is None: + bound_port = server.add_insecure_port(listen) + else: + config = self._admission + bound_port = server.add_secure_port( + listen, + grpc.ssl_server_credentials( + [ + ( + config.tls_private_key.read_bytes(), + config.tls_certificate.read_bytes(), + ) + ] + ), + ) + runner = web.AppRunner( + create_admission_application(self._middleware, config), + access_log=None, + ) + await runner.setup() + http_port = _validated_listen_port(config.listen) + http_host = config.listen.rsplit(":", 1)[0].strip("[]") + await web.TCPSite( + runner, + http_host, + http_port, + ssl_context=admission_tls_context(config), + ).start() if bound_port != requested_port: raise EgressGateError(ErrorCode.SERVER_BIND_FAILED) _LOGGER.info( @@ -70,7 +119,11 @@ async def serve_async(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: try: await _stop_grpc_server(server) finally: - await self._middleware.close() + try: + if runner is not None: + await runner.cleanup() + finally: + await self._middleware.close() _LOGGER = get_logger(__name__) @@ -78,9 +131,10 @@ async def serve_async(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: def _create_grpc_server( middleware: EgressGateMiddleware, + authentication: GatewayAuthentication | None = None, ) -> grpc.aio.Server: server = grpc.aio.server( - interceptors=(_MalformedProtobufInterceptor(),), + interceptors=(_MalformedProtobufInterceptor(authentication),), maximum_concurrent_rpcs=MAX_CONCURRENT_RPCS, options=(("grpc.max_receive_message_length", MAX_RECEIVE_MESSAGE_BYTES),), ) @@ -89,7 +143,10 @@ def _create_grpc_server( class _MalformedProtobufInterceptor(grpc.aio.ServerInterceptor): - """Map protobuf decoding failures to the public invalid-input contract.""" + """Authenticate RPCs and map decoding failures to content-safe statuses.""" + + def __init__(self, authentication: GatewayAuthentication | None = None) -> None: + self._authentication = authentication async def intercept_service( self, @@ -118,6 +175,15 @@ async def invoke_safely( request: object, context: grpc.aio.ServicerContext[object, object], ) -> object: + if self._authentication is not None: + try: + self._authentication.verify( + context.invocation_metadata() or (), request + ) + except (ValueError, jwt.PyJWTError): + await context.abort( + grpc.StatusCode.UNAUTHENTICATED, "authentication failed" + ) if request is _MALFORMED_PROTOBUF: await context.abort( grpc.StatusCode.INVALID_ARGUMENT, diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index a5099a08..22d68fb7 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -18,6 +18,16 @@ from google.protobuf import json_format from google.protobuf.message import Message +from egress_gate.admission import ( + PI_HARNESS_VERSION, + RECEIPT_HEADER, + AttestedEgressProcessor, + HarnessAdmissionContext, + HarnessAdmissionProcessor, + HarnessAdmissionRequest, + HarnessAdmissionResult, + ReceiptAuthority, +) from egress_gate.bindings import supervisor_middleware_pb2 as pb2 from egress_gate.bindings import supervisor_middleware_pb2_grpc as pb2_grpc from egress_gate.config import EgressGateConfig @@ -65,6 +75,7 @@ DecisionSourceKind, EgressDecision, EgressResult, + GateDecisionSource, SourcedFinding, ) from egress_gate.string_validators import validate_bounded_metadata_string @@ -83,6 +94,8 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float = DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, + require_agent_attestation: bool = False, + expected_audience: str = "", ) -> None: registry.configuration_json_schema() self._registry = registry @@ -90,6 +103,9 @@ def __init__( validate_timeout_middleware_processing(timeout_middleware_processing) ) self._policy = _ActivePolicy(registry) + self._receipt_authority = ReceiptAuthority() + self._require_agent_attestation = require_agent_attestation + self._expected_audience = expected_audience self._processing_slots = asyncio.Semaphore(MAX_CONCURRENT_PROCESSING) self._processing_executor = ThreadPoolExecutor( max_workers=MAX_CONCURRENT_PROCESSING, @@ -120,12 +136,13 @@ async def Describe( return pb2.MiddlewareManifest( name=SERVICE_NAME, service_version=SERVICE_VERSION, + expected_audience=self._expected_audience, bindings=[ pb2.MiddlewareBinding( operation=pb2.SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST, phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, - max_body_bytes=MAX_BODY_BYTES, - ) + max_payload_bytes=MAX_BODY_BYTES, + ), ], ) @@ -151,6 +168,24 @@ async def EvaluateHttpRequest( """Resolve the prepared pipeline and evaluate one current request.""" return await self._evaluate_rpc(request, context) + async def admit( + self, + request: HarnessAdmissionRequest, + context: HarnessAdmissionContext, + policy: dict[str, object], + ) -> HarnessAdmissionResult: + """Evaluate a candidate with HTTP-service-owned identity and policy.""" + if not self._require_agent_attestation: + raise ValueError("admission is disabled") + timeout = Timeout.from_seconds(self._timeout_middleware_processing_seconds) + return await self._run_in_worker( + lambda: HarnessAdmissionProcessor( + self._policy.processor_for(policy, timeout=timeout), + self._receipt_authority, + ).process(request, context, timeout=timeout), + timeout=timeout, + ) + def _validate_config( self, request: pb2.ValidateConfigRequest, @@ -175,6 +210,7 @@ async def _evaluate_rpc( request_id = _request_id_for_logging(request.context.request_id) failure: EgressGateError | None = None action = "error" + reason_code: str | None = None finding_count = 0 source_kind = "none" try: @@ -184,11 +220,13 @@ async def _evaluate_rpc( timeout, ) action = "allow" if response.decision == pb2.DECISION_ALLOW else "deny" + reason_code = response.reason_code or None finding_count = sum(finding.count for finding in response.findings) return response except TimeoutExpiredError: response = _limit_deny() action = "deny" + reason_code = response.reason_code or None source_kind = DecisionSourceKind.RUNTIME_LIMIT.value return response except EgressGateError as error: @@ -200,6 +238,7 @@ async def _evaluate_rpc( request_id=request_id, started=started, action=action, + reason_code=reason_code, finding_count=finding_count, source_kind=source_kind, failure=failure, @@ -255,6 +294,29 @@ def _prepare_and_process( values, timeout=timeout, ) + if self._require_agent_attestation: + return AttestedEgressProcessor( + processor, + self._receipt_authority, + middleware_name=request.middleware_name, + harness_version=PI_HARNESS_VERSION, + ).process( + domain_request, + timeout=timeout, + ) + if any( + header.name.lower() == RECEIPT_HEADER for header in domain_request.headers + ): + return EgressResult( + decision=EgressDecision.DENY, + decision_source=GateDecisionSource( + kind=DecisionSourceKind.GATE, + gate_name="reserved-receipt-header", + gate_type="reserved-receipt-header", + ), + reason_code="reserved_header_present", + policy_fingerprint=processor.policy_fingerprint, + ) return processor.process(domain_request, timeout=timeout) async def _run_in_worker( @@ -348,9 +410,11 @@ async def abort(self, code: grpc.StatusCode, details: str) -> Never: ... class _EvaluationLogExtra(TypedDict): + event: str request_id: str duration_ms: float action: str + reason_code: str | None finding_count: int decision_source_kind: str error_code: str | None @@ -361,14 +425,17 @@ def _evaluation_log_extra( request_id: str, started: float, action: str, + reason_code: str | None, finding_count: int, source_kind: str, failure: EgressGateError | None, ) -> _EvaluationLogExtra: return { + "event": "egress_gate_evaluation", "request_id": request_id, "duration_ms": round((time.monotonic() - started) * 1000, 3), "action": action, + "reason_code": reason_code, "finding_count": finding_count, "decision_source_kind": source_kind, "error_code": failure.code.value if failure is not None else None, diff --git a/projects/egress-gate/tests/admission/__init__.py b/projects/egress-gate/tests/admission/__init__.py new file mode 100644 index 00000000..a60fe663 --- /dev/null +++ b/projects/egress-gate/tests/admission/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Admission and attested-egress tests.""" diff --git a/projects/egress-gate/tests/admission/fixtures/README.md b/projects/egress-gate/tests/admission/fixtures/README.md new file mode 100644 index 00000000..2bfa46a7 --- /dev/null +++ b/projects/egress-gate/tests/admission/fixtures/README.md @@ -0,0 +1,21 @@ +# Pi provider fixture provenance + +The Chat Completions payloads were captured on 2026-09-02 from Pi commit +`61500e60394060f2f56a76c61a0067c33988c9f8` through the native stream +adapter's `onPayload` fake-fetch boundary. They preserve historical serializer +shapes as regression cases, not the current example's model configuration. +No provider request was sent during capture. + +The strict adapter decisions are deliberate: + +- Chat Completions accepts assistant `tool_calls`, tool replies, and the + `reasoning_content` string emitted when Pi replays Qwen reasoning. That + reasoning field is preserved for validation but is not projected as message + text. +- Unknown fields, explicit nulls for optional compatibility fields, image + inputs, and Responses requests are unsupported and fail closed. + +The current pinned Pi serializer is exercised directly by +`tests/service/test_http_admission.py`, which runs the real application client +against local admission and provider endpoints. Provider responses are controlled +test data; the runnable example's real-model verification is separate. diff --git a/projects/egress-gate/tests/admission/fixtures/pi-openai-completions.json b/projects/egress-gate/tests/admission/fixtures/pi-openai-completions.json new file mode 100644 index 00000000..d912da65 --- /dev/null +++ b/projects/egress-gate/tests/admission/fixtures/pi-openai-completions.json @@ -0,0 +1,130 @@ +{ + "user_request": { + "model": "azure/anthropic/claude-opus-5", + "messages": [ + {"role": "system", "content": "fixture system prompt"}, + {"role": "user", "content": "safe"} + ], + "stream": true, + "stream_options": {"include_usage": true}, + "store": false, + "max_tokens": 128000, + "tools": [ + { + "type": "function", + "function": { + "name": "read", + "description": "Read a file", + "parameters": {"type": "object", "properties": {}}, + "strict": false + } + } + ] + }, + "opus": { + "model": "azure/anthropic/claude-opus-5", + "messages": [ + {"role": "system", "content": "fixture system prompt"}, + {"role": "user", "content": "use the tool"}, + { + "role": "assistant", + "content": "I will read it.", + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": {"name": "read", "arguments": "{}"} + } + ] + }, + {"role": "tool", "content": "safe tool output", "tool_call_id": "call-1"} + ], + "stream": true, + "stream_options": {"include_usage": true}, + "store": false, + "max_tokens": 128000, + "tools": [ + { + "type": "function", + "function": { + "name": "read", + "description": "Read a file", + "parameters": {"type": "object", "properties": {}}, + "strict": false + } + } + ] + }, + "qwen": { + "model": "nvidia/qwen/qwen3.8-flash-next", + "messages": [ + {"role": "system", "content": "fixture system prompt"}, + {"role": "user", "content": "use the tool"}, + { + "role": "assistant", + "content": null, + "reasoning_content": "prior reasoning", + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": {"name": "read", "arguments": "{}"} + } + ] + }, + {"role": "tool", "content": "safe tool output", "tool_call_id": "call-1"} + ], + "stream": true, + "stream_options": {"include_usage": true}, + "store": false, + "max_tokens": 32768, + "tools": [ + { + "type": "function", + "function": { + "name": "read", + "description": "Read a file", + "parameters": {"type": "object", "properties": {}}, + "strict": false + } + } + ], + "enable_thinking": true, + "reasoning_effort": "high" + }, + "compaction_summary": { + "model": "azure/anthropic/claude-opus-5", + "messages": [ + {"role": "system", "content": "fixture system prompt"}, + {"role": "user", "content": "use the tool"}, + { + "role": "assistant", + "content": "I will read it.", + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": {"name": "read", "arguments": "{}"} + } + ] + }, + {"role": "tool", "content": "safe tool output", "tool_call_id": "call-1"}, + {"role": "user", "content": "Summary:\ncompacted context"} + ], + "stream": true, + "stream_options": {"include_usage": true}, + "store": false, + "max_tokens": 128000, + "tools": [ + { + "type": "function", + "function": { + "name": "read", + "description": "Read a file", + "parameters": {"type": "object", "properties": {}}, + "strict": false + } + } + ] + } +} diff --git a/projects/egress-gate/tests/admission/test_admission.py b/projects/egress-gate/tests/admission/test_admission.py new file mode 100644 index 00000000..45ecb52a --- /dev/null +++ b/projects/egress-gate/tests/admission/test_admission.py @@ -0,0 +1,565 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Boundary cases not covered by the native Pi HTTP/RPC integration journeys.""" + +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +import pytest +import yaml + +from egress_gate.admission import ( + MAX_ADMISSION_BODY_BYTES, + RECEIPT_HEADER, + AdmissionDecision, + AdmissionHook, + AdmissionProvenance, + AttestedEgressProcessor, + HarnessAdmissionContext, + HarnessAdmissionProcessor, + HarnessAdmissionRequest, + PiAssistantMessageV1, + PiAssistantToolCallV1, + PiMessageV1, + PiProviderContextV1, + ReceiptAuthority, + canonical_json_bytes, + extract_provider_entries, +) +from egress_gate.gates import create_builtin_registry +from egress_gate.request import HttpHeader, HttpRequest, HttpTarget, RequestContext +from egress_gate.timeout import Timeout + +DENY_TEXT = "DENY_THIS" +REDACT_TEXT = "REDACT_THIS" +# Captured serializer payloads remain useful regression fixtures; live serializer +# coverage is exercised by the cross-language service test. +_PI_CHAT_FIXTURES = json.loads( + (Path(__file__).parent / "fixtures/pi-openai-completions.json").read_text() +) + + +def _processors( + *, replacement_template: str = "[REDACTED]" +) -> tuple[HarnessAdmissionProcessor, AttestedEgressProcessor, ReceiptAuthority]: + registry = create_builtin_registry() + policy = yaml.safe_load( + ( + Path(__file__).parents[2] / "examples/pi-attested-admission/policy.yaml" + ).read_text() + )["network_middlewares"]["pi_egress_gate"]["config"] + policy["gates"][1]["scan"]["action"]["template"] = replacement_template + config = registry.validate_config(policy) + request_processor = registry.prepare_processor( + config, timeout=Timeout.from_seconds(1) + ) + authority = ReceiptAuthority() + return ( + HarnessAdmissionProcessor(request_processor, authority), + AttestedEgressProcessor( + request_processor, + authority, + middleware_name="pi-egress", + harness_version="sdk-v1", + ), + authority, + ) + + +def _target(*, host: str = "provider.test") -> HttpTarget: + return HttpTarget( + scheme="https", + host=host, + port=443, + method="POST", + path="/v1/chat/completions", + query="", + ) + + +def _context( + hook: AdmissionHook, + *, + target: HttpTarget | None = None, +) -> HarnessAdmissionContext: + schema = { + AdmissionHook.SYSTEM_CONTEXT: "openshell.pi-message.v1", + AdmissionHook.USER_MESSAGE: "openshell.pi-message.v1", + AdmissionHook.COMPACTION_SUMMARY: "openshell.pi-message.v1", + AdmissionHook.TOOL_RESULT: "openshell.pi-tool-result.v1", + AdmissionHook.ASSISTANT_MESSAGE: "openshell.pi-assistant-message.v1", + AdmissionHook.PROVIDER_CONTEXT: "openshell.pi-provider-context.v1", + }[hook] + return HarnessAdmissionContext( + request_id="admission-1", + sandbox_id="sandbox-1", + middleware_name="pi-egress", + harness="pi", + harness_version="sdk-v1", + hook=hook, + schema_version=schema, + provider_target=target or _target(), + provider_adapter_schema="openai.request.v1", + ) + + +def _admit( + processor: HarnessAdmissionProcessor, + value: PiMessageV1 | PiAssistantMessageV1 | PiProviderContextV1, + *, + target: HttpTarget | None = None, + timeout: Timeout | None = None, +): + if isinstance(value, PiMessageV1): + hook = { + "user": AdmissionHook.USER_MESSAGE, + "system": AdmissionHook.SYSTEM_CONTEXT, + "compaction_summary": AdmissionHook.COMPACTION_SUMMARY, + }[value.origin] + elif isinstance(value, PiAssistantMessageV1): + hook = AdmissionHook.ASSISTANT_MESSAGE + else: + hook = AdmissionHook.PROVIDER_CONTEXT + return processor.process( + HarnessAdmissionRequest( + request_body=canonical_json_bytes(value), + provenance=AdmissionProvenance( + session_id="session-1", submission_id="submission-1" + ), + ), + _context(hook, target=target), + timeout=timeout or Timeout.from_seconds(1), + ) + + +def _user(text: str) -> PiMessageV1: + return PiMessageV1( + schema_version="openshell.pi-message.v1", origin="user", text=text + ) + + +def _assistant( + text: str, *, arguments: dict[str, object] | None = None +) -> PiAssistantMessageV1: + return PiAssistantMessageV1( + schema_version="openshell.pi-assistant-message.v1", + text=text, + tool_calls=( + PiAssistantToolCallV1( + id="call-1", name="read", arguments=arguments or {"path": "safe"} + ), + ), + ) + + +def _provider_request( + prompt: str, + *, + tool_result: str | None = None, + headers: tuple[HttpHeader, ...] = (), + target: HttpTarget | None = None, +) -> HttpRequest: + provider_body = json.loads(json.dumps(_PI_CHAT_FIXTURES["user_request"])) + provider_body["messages"][1]["content"] = prompt + if tool_result is not None: + provider_body["messages"].extend( + [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": {"name": "read", "arguments": "{}"}, + } + ], + }, + { + "role": "tool", + "content": tool_result, + "tool_call_id": "call-1", + }, + ] + ) + body = json.dumps( + provider_body, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode() + return HttpRequest( + context=RequestContext(request_id="network-1", sandbox_id="sandbox-1"), + target=target or _target(), + headers=(HttpHeader(name="content-type", value="application/json"),) + headers, + body=body, + ) + + +def _egress( + processor: AttestedEgressProcessor, + request: HttpRequest, + attestation: bytes | None, +): + if attestation: + request = request.model_copy( + update={ + "headers": ( + *request.headers, + HttpHeader( + name=RECEIPT_HEADER, + value=base64.urlsafe_b64encode(attestation).decode(), + ), + ) + } + ) + return processor.process(request, timeout=Timeout.from_seconds(1)) + + +def _admit_provider_request( + admission: HarnessAdmissionProcessor, + request: HttpRequest, +): + return _admit( + admission, + PiProviderContextV1( + schema_version="openshell.pi-provider-context.v1", + entries=extract_provider_entries(request, Timeout.from_seconds(1)), + ), + target=request.target, + ) + + +@pytest.mark.parametrize("fixture_name", ["opus", "qwen", "compaction_summary"]) +def test_complete_pi_chat_context_is_attested(fixture_name: str) -> None: + admission, egress, _ = _processors() + request = _provider_request("safe").model_copy( + update={ + "body": json.dumps( + _PI_CHAT_FIXTURES[fixture_name], + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode() + } + ) + admitted = _admit_provider_request(admission, request) + + result = _egress(egress, request, admitted.attestation) + + assert admitted.attestation is not None + assert admitted.attestation.startswith(b"ag2.") + assert result.decision.value == "allow" + assert _egress(egress, request, admitted.attestation).decision.value == "allow" + + +@pytest.mark.parametrize( + ("mutation", "reason_code"), + [ + ( + lambda body: body["messages"][1].update({"content": "changed prompt"}), + "context_hash_mismatch", + ), + ( + lambda body: body["messages"].append({"role": "user", "content": "extra"}), + "entry_count_mismatch", + ), + (lambda body: body["messages"].pop(), "entry_count_mismatch"), + ], +) +def test_chat_context_tampering_is_denied(mutation, reason_code: str) -> None: + admission, egress, _ = _processors() + request = _provider_request("use the tool", tool_result="safe tool output") + admitted = _admit_provider_request(admission, request) + body = json.loads(request.body) + mutation(body) + changed = request.model_copy( + update={ + "body": json.dumps( + body, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode() + } + ) + + result = _egress(egress, changed, admitted.attestation) + + assert result.reason_code == reason_code + + +def test_provider_context_redaction_binds_only_the_replacement() -> None: + admission, egress, _ = _processors() + original = _provider_request(f"hide {REDACT_TEXT} please") + admitted = _admit_provider_request(admission, original) + + assert admitted.decision is AdmissionDecision.REPLACE + assert admitted.attestation is not None + assert admitted.replacement_body is not None + replacement = PiProviderContextV1.model_validate_json( + admitted.replacement_body, strict=True + ) + replaced = _provider_request(replacement.entries[0].text) + + assert _egress(egress, replaced, admitted.attestation).decision.value == "allow" + assert ( + _egress(egress, original, admitted.attestation).reason_code + == "context_hash_mismatch" + ) + + +def test_attestation_uses_stable_destination_across_tls_proxy_normalization() -> None: + admission, egress, _ = _processors() + normalized = HttpTarget( + scheme="http", + host="provider.test", + port=443, + method="POST", + path="/v1/chat/completions", + query="", + ) + request = _provider_request("safe", target=normalized) + admitted = _admit_provider_request(admission, request) + + allowed = _egress(egress, request, admitted.attestation) + wrong_host = _egress( + egress, + _provider_request( + "safe", target=normalized.model_copy(update={"host": "other.test"}) + ), + admitted.attestation, + ) + + assert allowed.decision.value == "allow" + assert wrong_host.reason_code == "attestation_context_mismatch" + + +def test_tool_images_fail_closed() -> None: + admission, _, _ = _processors() + body = { + "schema_version": "openshell.pi-tool-result.v1", + "tool_call_id": "call-1", + "tool_name": "read", + "is_error": False, + "content": [{"type": "image", "data": "AA==", "mimeType": "image/png"}], + } + result = admission.process( + HarnessAdmissionRequest( + request_body=json.dumps(body).encode(), + provenance=AdmissionProvenance( + session_id="session-1", submission_id="image" + ), + ), + _context(AdmissionHook.TOOL_RESULT), + timeout=Timeout.from_seconds(1), + ) + assert result.reason_code == "admission_contract_invalid" + + +def test_text_message_binding_rejects_a_different_origin() -> None: + admission, _, _ = _processors() + value = _user("safe") + + result = admission.process( + HarnessAdmissionRequest( + request_body=canonical_json_bytes(value), + provenance=AdmissionProvenance( + session_id="session-1", submission_id="submission-1" + ), + ), + _context(AdmissionHook.COMPACTION_SUMMARY), + timeout=Timeout.from_seconds(1), + ) + + assert result.reason_code == "admission_contract_invalid" + + +def test_assistant_message_accepts_javascript_number_serialization() -> None: + admission, _, _ = _processors() + body = ( + b'{"schema_version":"openshell.pi-assistant-message.v1","text":"safe",' + b'"tool_calls":[{"arguments":{"threshold":1e-7},"id":"call-1",' + b'"name":"read"}]}' + ) + + result = admission.process( + HarnessAdmissionRequest( + request_body=body, + provenance=AdmissionProvenance( + session_id="session-1", submission_id="submission-1" + ), + ), + _context(AdmissionHook.ASSISTANT_MESSAGE), + timeout=Timeout.from_seconds(1), + ) + + assert result.decision is AdmissionDecision.ALLOW + + +@pytest.mark.parametrize("field", ["tool_calls", "thinking"]) +def test_assistant_message_rejects_replay_mutation(field: str) -> None: + admission, _, _ = _processors() + + body = _assistant("safe", arguments={"path": REDACT_TEXT}).model_dump(mode="json") + if field == "thinking": + body["tool_calls"] = [] + body["thinking"] = [{"text": REDACT_TEXT, "signature": "provider-signature"}] + result = _admit(admission, PiAssistantMessageV1.model_validate(body, strict=True)) + + assert result.decision is AdmissionDecision.DENY + assert result.reason_code == "admission_contract_invalid" + + +@pytest.mark.parametrize( + "context_update", + [{"harness": "unknown"}, {"schema_version": "openshell.unknown.v1"}], +) +def test_unknown_harness_binding_fails_closed(context_update: dict[str, str]) -> None: + admission, _, _ = _processors() + result = admission.process( + HarnessAdmissionRequest( + request_body=canonical_json_bytes(_user("safe")), + provenance=AdmissionProvenance( + session_id="session-1", submission_id="submission-1" + ), + ), + _context(AdmissionHook.USER_MESSAGE).model_copy(update=context_update), + timeout=Timeout.from_seconds(1), + ) + + assert result.reason_code == "admission_contract_invalid" + + +def test_oversized_redaction_attempt_fails_before_attestation_issuance() -> None: + admission, _, _ = _processors(replacement_template="x" * 1024) + + denied = _admit( + admission, + _user(REDACT_TEXT * (MAX_ADMISSION_BODY_BYTES // 1024 + 1)), + ) + + assert denied.decision is AdmissionDecision.DENY + assert denied.reason_code == "egress_gate_limit_exceeded" + assert denied.attestation is None + + +def test_malformed_duplicate_and_expired_admission_fail_closed() -> None: + admission, _, _ = _processors() + provenance = AdmissionProvenance( + session_id="session-1", submission_id="submission-1" + ) + + def admit_body(body: bytes, timeout: Timeout | None = None): + return admission.process( + HarnessAdmissionRequest(request_body=body, provenance=provenance), + _context(AdmissionHook.USER_MESSAGE), + timeout=timeout or Timeout.from_seconds(1), + ) + + malformed = admit_body(b"{") + missing_origin = admit_body( + b'{"schema_version":"openshell.pi-message.v1","text":"safe"}' + ) + duplicate = admit_body( + b'{"origin":"user","schema_version":"openshell.pi-message.v1",' + b'"schema_version":"openshell.pi-message.v1","text":"safe"}' + ) + over_depth = admit_body(b"[" * 129 + b"0" + b"]" * 129) + expired = admit_body(b"{}", Timeout(deadline=0.0)) + + assert malformed.reason_code == "admission_contract_invalid" + assert missing_origin.reason_code == "admission_contract_invalid" + assert duplicate.reason_code == "admission_contract_invalid" + assert over_depth.reason_code == "admission_unavailable" + assert expired.reason_code == "admission_unavailable" + + +def test_provider_shape_validation_and_optional_reasoning_field_are_preserved() -> None: + admission, egress, _ = _processors() + request = _provider_request("safe") + admitted = _admit_provider_request(admission, request) + malformed = request.model_copy(update={"body": b"{"}) + provider_body = json.loads(request.body) + provider_body["reasoning_effort"] = "medium" + with_reasoning = request.model_copy( + update={ + "body": json.dumps( + provider_body, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode() + } + ) + + malformed_result = _egress(egress, malformed, admitted.attestation) + reasoning_result = _egress(egress, with_reasoning, admitted.attestation) + + assert malformed_result.reason_code == "provider_shape_unsupported" + assert reasoning_result.decision.value == "allow" + + +@pytest.mark.parametrize( + "mutation", + [ + lambda body: body.update({"max_completion_tokens": 128}), + lambda body: body.update({"stream_options": {"include_usage": "true"}}), + lambda body: body["tools"][0].update( + {"cache_control": {"type": "ephemeral", "ttl": "forever"}} + ), + lambda body: body["messages"][1].update({"tool_call_id": "wrong-role"}), + lambda body: body["messages"][1].update({"role": "tool"}), + lambda body: body["tools"][0]["function"].update( + {"parameters": {"limit": float("inf")}} + ), + ], +) +def test_unsupported_chat_shapes_fail_closed(mutation) -> None: + admission, egress, _ = _processors() + request = _provider_request("safe") + admitted = _admit_provider_request(admission, request) + body = json.loads(request.body) + mutation(body) + malformed = request.model_copy( + update={ + "body": json.dumps( + body, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode() + } + ) + + result = _egress(egress, malformed, admitted.attestation) + + assert result.reason_code == "provider_shape_unsupported" + + +@pytest.mark.parametrize( + "mutation", + [ + lambda body: body["messages"][2].update({"reasoning_content": None}), + lambda body: body["messages"][2].update({"unknown_replay_field": "value"}), + ], +) +def test_qwen_replay_fields_fail_closed_unless_explicitly_supported(mutation) -> None: + admission, egress, _ = _processors() + body = json.loads(json.dumps(_PI_CHAT_FIXTURES["qwen"])) + original = _provider_request("unused").model_copy( + update={"body": json.dumps(body, separators=(",", ":")).encode()} + ) + admitted = _admit_provider_request(admission, original) + mutation(body) + request = _provider_request("unused").model_copy( + update={"body": json.dumps(body, separators=(",", ":")).encode()} + ) + + result = _egress(egress, request, admitted.attestation) + + assert result.reason_code == "provider_shape_unsupported" diff --git a/projects/egress-gate/tests/service/test_grpc_integration.py b/projects/egress-gate/tests/service/test_grpc_integration.py index 1ecbec0e..5771d02b 100644 --- a/projects/egress-gate/tests/service/test_grpc_integration.py +++ b/projects/egress-gate/tests/service/test_grpc_integration.py @@ -153,6 +153,24 @@ async def test_generated_stub_round_trip_covers_manifest_and_gate_actions() -> N assert denied.reason_code == "egress_gate_regex_denied" +@pytest.mark.asyncio +async def test_unmanaged_http_rejects_the_reserved_header() -> None: + middleware = EgressGateMiddleware(create_builtin_registry()) + request = _evaluation(b"safe", action_kind="detect") + request.headers.append( + pb2.HttpHeader( + name="X-Egress-Admission", + value="eg1.untrusted", + ) + ) + + async with _running_stub(middleware) as (stub, _): + response = await stub.EvaluateHttpRequest(request) + + assert response.decision == pb2.DECISION_DENY + assert response.reason_code == "reserved_header_present" + + @pytest.mark.asyncio async def test_generated_stub_returns_three_gate_progressive_redaction() -> None: middleware = EgressGateMiddleware(create_builtin_registry()) diff --git a/projects/egress-gate/tests/service/test_http_admission.py b/projects/egress-gate/tests/service/test_http_admission.py new file mode 100644 index 00000000..c2375bd9 --- /dev/null +++ b/projects/egress-gate/tests/service/test_http_admission.py @@ -0,0 +1,585 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Real local HTTP/RPC transport tests; model traffic uses checked-in fixtures.""" + +from __future__ import annotations + +import asyncio +import json +import os +import pty +import runpy +import shutil +import termios +import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path + +import grpc +import jwt +import pytest +import yaml +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from google.protobuf import empty_pb2, json_format, message_factory + +from egress_gate.admission import RECEIPT_HEADER +from egress_gate.bindings import supervisor_middleware_pb2 as pb +from egress_gate.bindings import supervisor_middleware_pb2_grpc as rpc +from egress_gate.gates import create_builtin_registry +from egress_gate.service.admission import ( + AdmissionServerConfig, + admission_tls_context, + create_admission_application, +) +from egress_gate.service.authentication import GatewayAuthentication +from egress_gate.service.server import _create_grpc_server +from egress_gate.service.servicer import EgressGateMiddleware + +PROJECT = Path(__file__).resolve().parents[2] +AUDIENCE = "urn:openshell:extension:middleware:pi-egress" +AUTHORIZATION = {"Authorization": "Bearer test-admission-credential"} + + +@pytest.mark.asyncio +async def test_http_admission_allow_deny_replace_and_authentication( + tmp_path: Path, +) -> None: + async with _clients(tmp_path) as (client, _, config, _, _): + denied_auth = await client.post("/v1/admission", json=_call("safe")) + assert denied_auth.status == 401 + for text, expected in ( + ("safe", "allow"), + ("DENY_THIS", "deny"), + ("REDACT_THIS", "replace"), + ): + response = await client.post( + "/v1/admission", json=_call(text), headers=AUTHORIZATION + ) + assert response.status == 200 + result = await response.json() + assert result["decision"] == expected, result["reason_code"] + assert result["receipt"] is None + if expected == "replace": + assert result["replacement"]["text"] == "[REDACTED]" + forged = {**_call("safe"), "sandbox_id": "somebody-else"} + response = await client.post( + "/v1/admission", json=forged, headers=AUTHORIZATION + ) + assert response.status == 400 + config.sandbox_id_file.unlink() + response = await client.post( + "/v1/admission", json=_call("safe"), headers=AUTHORIZATION + ) + assert response.status == 503 + + +@pytest.mark.asyncio +async def test_http_receipt_is_verified_and_stripped_by_standard_authenticated_rpc( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + async with _clients(tmp_path) as (client, stub, config, token, _): + response = await client.post( + "/v1/admission", + json=_call("safe", kind="provider_context"), + headers=AUTHORIZATION, + ) + result = await response.json() + assert result["decision"] == "allow", result["reason_code"] + assert result["receipt"] + request = _network(config, result["receipt"]) + metadata = (("authorization", f"Bearer {token}"),) + allowed = await stub.EvaluateHttpRequest(request, metadata=metadata) + assert allowed.decision == pb.DECISION_ALLOW + assert allowed.header_mutations[-1].remove.name == RECEIPT_HEADER + with pytest.raises(grpc.aio.AioRpcError) as failure: + await stub.EvaluateHttpRequest(request) + assert failure.value.code() == grpc.StatusCode.UNAUTHENTICATED + request.context.sandbox_id = "forged" + with pytest.raises(grpc.aio.AioRpcError) as failure: + await stub.EvaluateHttpRequest(request, metadata=metadata) + assert failure.value.code() == grpc.StatusCode.UNAUTHENTICATED + request.context.sandbox_id = "sandbox" + request.headers.pop() + denied = await stub.EvaluateHttpRequest(request, metadata=metadata) + assert denied.reason_code == "attestation_missing" + request.headers.add(name=RECEIPT_HEADER, value=result["receipt"]) + request.headers.add(name=RECEIPT_HEADER, value=result["receipt"]) + denied = await stub.EvaluateHttpRequest(request, metadata=metadata) + assert denied.reason_code == "attestation_malformed" + # Issue a genuinely signed but expired receipt, without a wall-clock wait. + with monkeypatch.context() as clock: + clock.setattr("egress_gate.admission.receipts._now_seconds", lambda: 0) + response = await client.post( + "/v1/admission", + json=_call("safe", kind="provider_context"), + headers=AUTHORIZATION, + ) + expired = await response.json() + denied = await stub.EvaluateHttpRequest( + _network(config, expired["receipt"]), metadata=metadata + ) + assert denied.reason_code == "attestation_expired" + empty = message_factory.GetMessageClass( + empty_pb2.DESCRIPTOR.message_types_by_name["Empty"] + )() + manifest = await stub.Describe(empty, metadata=metadata) + assert manifest.expected_audience == AUDIENCE + assert len(manifest.bindings) == 1 + assert ( + manifest.bindings[0].operation + == pb.SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST + ) + assert not hasattr(pb.HttpRequestEvaluation(), "agent_attestation") + + +@pytest.mark.parametrize("change", ["issuer", "audience", "expired", "type", "key"]) +def test_gateway_authentication_rejects_invalid_trust_claims(change: str) -> None: + key = Ed25519PrivateKey.generate() + public = key.public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + ) + claims = { + "iss": "trusted-gateway", + "aud": AUDIENCE, + "iat": int(time.time()) - 10, + "exp": int(time.time()) + 60, + "caller_kind": "supervisor", + "sandbox_id": "sandbox", + } + if change == "issuer": + claims["iss"] = "some-other-gateway" + elif change == "audience": + claims["aud"] = "another-service" + elif change == "expired": + claims["exp"] = int(time.time()) - 1 + token = jwt.encode( + claims, + Ed25519PrivateKey.generate() if change == "key" else key, + algorithm="EdDSA", + headers={"typ": "JWT" if change == "type" else "openshell-ext+jwt"}, + ) + request = pb.HttpRequestEvaluation(context=pb.RequestContext(sandbox_id="sandbox")) + with pytest.raises((ValueError, jwt.PyJWTError)): + GatewayAuthentication(public, "trusted-gateway", AUDIENCE).verify( + (("authorization", f"Bearer {token}"),), request + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("mode", "max_tokens", "cache_control", "cache_retention"), + [ + ("tui", 2048, False, ""), + ("sdk", 16384, True, "long"), + ("settings", 2048, True, ""), + ("settings", 16384, False, ""), + ], + ids=["native-tui", "sdk-long-cache", "compat-cache", "default-cache"], +) +async def test_pi_session_through_admission_and_authenticated_egress( + tmp_path: Path, + unused_tcp_port: int, + mode: str, + max_tokens: int, + cache_control: bool, + cache_retention: str, +) -> None: + tui = mode == "tui" + settings_only = mode == "settings" + source = PROJECT / "examples/pi-attested-admission" + example = tmp_path / "example" + shutil.copytree( + source, + example, + ignore=shutil.ignore_patterns( + ".env", "model.json", "models.json", "node_modules", "dist" + ), + ) + catalog = json.loads((example / "models.json.example").read_text()) + model = catalog["providers"]["openrouter"]["models"][0] + model["maxTokens"] = max_tokens + model["samplingParams"] = {"temperature": 0.25, "top_p": 0.9} + model["compat"] = { + "supportsUsageInStreaming": not tui, + "sendSessionAffinityHeaders": True, + "sessionAffinityFormat": "openai", + "supportsLongCacheRetention": True, + } + if cache_control: + model["compat"]["cacheControlFormat"] = "anthropic" + (example / "models.json").write_text(json.dumps(catalog)) + # Match the image: the sandbox user cannot create Pi auth/cache files in /app. + agent_dir = tmp_path / "agent" + agent_dir.mkdir(mode=0o555) + async with _clients(tmp_path) as (_, stub, config, token, middleware): + runpy.run_path(str(example / "prepare.py"))["prepare"]( + example, + tmp_path, + "127.0.0.1", + config.gateway_public_key, + config.gateway_issuer, + ) + config = config.model_copy( + update={ + "tls_certificate": tmp_path / "tls/server/tls.crt", + "tls_private_key": tmp_path / "tls/server/tls.key", + "provider_target": config.provider_target.model_copy( + update={ + "host": "127.0.0.1", + "port": unused_tcp_port, + "path": "/api/v1/chat/completions", + } + ), + } + ) + calls: list[bytes] = [] + session_ids: list[str | None] = [] + chat_calls: list[bytes] = [] + summary_calls: list[bytes] = [] + + async def provider(request: web.Request) -> web.Response: + body = await request.read() + evaluation = pb.HttpRequestEvaluation( + phase=pb.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, + context=pb.RequestContext(sandbox_id="sandbox", request_id="pi"), + target=pb.HttpRequestTarget(**config.provider_target.model_dump()), + middleware_name=config.middleware_name, + body=body, + headers=[ + pb.HttpHeader(name=k, value=v) for k, v in request.headers.items() + ], + ) + json_format.ParseDict(config.policy, evaluation.config) + metadata = (("authorization", f"Bearer {token}"),) + result = await stub.EvaluateHttpRequest(evaluation, metadata=metadata) + assert result.decision == pb.DECISION_ALLOW, result.reason_code + assert not result.has_body or result.body == body + assert result.header_mutations[-1].remove.name == RECEIPT_HEADER + assert ( + "REDACT_THIS" not in body.decode() and "DENY_THIS" not in body.decode() + ) + calls.append(body) + payload = json.loads(body) + summary = "You are a context summarization assistant." in json.dumps( + payload["messages"][0] + ) + (summary_calls if summary else chat_calls).append(body) + affinity = request.headers.get("x-session-affinity") + # Both native history and split-turn summaries disable caching. + assert (affinity is None) == summary + if not summary: + session_ids.append(affinity) + if cache_retention == "long" and not summary: + assert payload["prompt_cache_key"] == session_ids[-1] + assert payload["prompt_cache_retention"] == "24h" + else: + assert "prompt_cache_key" not in payload + assert "prompt_cache_retention" not in payload + if cache_control and not summary: + expected_cache = {"type": "ephemeral"} + if cache_retention == "long": + expected_cache["ttl"] = "1h" + assert payload["messages"][0]["content"][-1]["cache_control"] == ( + expected_cache + ) + assert payload["messages"][-1]["content"][-1]["cache_control"] == ( + expected_cache + ) + assert payload["tools"][-1]["cache_control"] == expected_cache + else: + assert '"cache_control"' not in body.decode() + assert ("stream_options" in payload) is not tui + assert payload["temperature"] == 0.25 + assert payload["top_p"] == 0.9 + assert payload["reasoning"] == {"effort": "high"} + if len(chat_calls) == 3 and not summary: + assistant = next( + m for m in reversed(payload["messages"]) if m["role"] == "assistant" + ) + assert assistant["reasoning_details"] == [ + { + "type": "reasoning.text", + "text": "APPROVED_REASONING", + "signature": "provider-signature", + "id": "reasoning-1", + } + ] + if not summary: + # Real Pi serialization must honor limits below and above 4096. + assert payload["max_tokens"] == max_tokens + elif not tui and len(chat_calls) == 3 and len(summary_calls) == 1: + # Pi's default compaction reserve is 16384; its summary uses 80%. + assert payload["max_tokens"] == min(int(0.8 * 16384), max_tokens) + if len(calls) == 1: + changed = json.loads(body) + next(m for m in changed["messages"] if m["role"] == "user")[ + "content" + ] = "tampered" + evaluation.body = json.dumps(changed).encode() + denied = await stub.EvaluateHttpRequest(evaluation, metadata=metadata) + assert denied.reason_code == "context_hash_mismatch" + assert len(chat_calls) <= 5, "unexpected chat call" + delta: dict[str, object] = {"role": "assistant", "content": "REDACT_THIS"} + delta["reasoning"] = "REDACT_THIS" + finish = "stop" + if not summary and len(chat_calls) == 2: + delta = { + "role": "assistant", + "reasoning": "APPROVED_REASONING", + "reasoning_details": [ + { + "type": "reasoning.text", + "text": "APPROVED_REASONING", + "signature": "provider-signature", + "id": "reasoning-1", + } + ], + "tool_calls": [ + { + "index": 0, + "id": "call-1", + "type": "function", + "function": { + "name": "read", + "arguments": '{"path":"notes.txt"}', + }, + } + ], + } + finish = "tool_calls" + elif summary: + delta["content"] = "Approved summary" + chunk = { + "id": "local", + "object": "chat.completion.chunk", + "created": 0, + "model": "test", + "choices": [{"index": 0, "delta": delta, "finish_reason": finish}], + } + return web.Response( + text=f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n", + content_type="text/event-stream", + ) + + application = create_admission_application(middleware, config) + application.router.add_post("/api/v1/chat/completions", provider) + server = TestServer(application, scheme="https", port=unused_tcp_port) + await server.start_server(ssl=admission_tls_context(config)) + terminal: tuple[int, int] | None = None + try: + if tui: + terminal = pty.openpty() + termios.tcsetwinsize(terminal[1], (35, 110)) + os.set_blocking(terminal[0], False) + process = await asyncio.create_subprocess_exec( + "node", + str(source / "pi-harness/dist/test/service-integration.js"), + str(server.make_url("/")).rstrip("/"), + str(tmp_path), + *(["--tui"] if tui else ["--settings"] if settings_only else []), + env=os.environ + | { + "NODE_EXTRA_CA_CERTS": str(tmp_path / "tls/ca.crt"), + "PI_OFFLINE": "1", + "PI_CODING_AGENT_DIR": str(tmp_path / "agent"), + "TERM": "xterm-256color", + "PI_CACHE_RETENTION": cache_retention, + }, + stdin=terminal[1] if terminal else None, + stdout=terminal[1] if terminal else asyncio.subprocess.PIPE, + stderr=terminal[1] if terminal else asyncio.subprocess.PIPE, + ) + try: + if terminal: + await _terminal_until(terminal[0], b"notes.txt") + os.write(terminal[0], b"\x0f") # Pi's expand-tool-output shortcut. + await _terminal_until(terminal[0], b"This is a real file") + os.write(terminal[0], b"/compact\r") + await _terminal_until(terminal[0], b"Approved summary") + os.write(terminal[0], b"DENY_THIS\r") + await _terminal_until(terminal[0], b"Admission denied") + os.write(terminal[0], b"/new\r") + await _terminal_until(terminal[0], b"New session started") + os.write(terminal[0], b"DENY_THIS\r") + await _terminal_until(terminal[0], b"Admission denied") + os.write(terminal[0], b"/quit\r") + await asyncio.wait_for(process.wait(), 10) + saved = "\n".join( + path.read_text() + for path in (tmp_path / "sessions").glob("*.jsonl") + ) + assert "[REDACTED]" in saved and "Approved summary" in saved + assert "DENY_THIS" not in saved and "REDACT_THIS" not in saved + stdout = stderr = b"" + else: + stdout, stderr = await asyncio.wait_for(process.communicate(), 30) + finally: + if process.returncode is None: + process.kill() + await process.wait() + assert process.returncode == 0, (stdout + stderr).decode() + assert not list(agent_dir.iterdir()), ( + "Pi must not write auth or model caches" + ) + assert len(chat_calls) == (1 if settings_only else 3 if tui else 5) + # Native split-turn compaction may issue multiple summary requests. + assert bool(summary_calls) == (not settings_only) + assert len(set(session_ids[:3])) == 1 + if not tui and not settings_only: + assert len(set(session_ids[3:5])) == 1 + assert session_ids[0] != session_ids[3] + if not settings_only: + assert any( + m["role"] == "tool" for m in json.loads(calls[2])["messages"] + ) + finally: + if terminal: + os.close(terminal[0]) + os.close(terminal[1]) + await server.close() + + +async def _terminal_until(fd: int, expected: bytes) -> None: + output = b"" + try: + async with asyncio.timeout(20): + while expected not in output: + try: + output += os.read(fd, 65536) + except BlockingIOError: + pass + await asyncio.sleep(0.02) + except TimeoutError: + pytest.fail( + f"Terminal did not show {expected!r}: {output.decode(errors='replace')}" + ) + + +@asynccontextmanager +async def _clients( + directory: Path, +) -> AsyncIterator[ + tuple[ + TestClient[web.Request, web.Application], + rpc.SupervisorMiddlewareStub, + AdmissionServerConfig, + str, + EgressGateMiddleware, + ] +]: + key = Ed25519PrivateKey.generate() + public = key.public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + ) + (directory / "public.pem").write_bytes(public) + (directory / "sandbox-id").write_text("sandbox") + policy = yaml.safe_load( + (PROJECT / "examples/pi-attested-admission/policy.yaml").read_text() + )["network_middlewares"]["pi_egress_gate"]["config"] + config = AdmissionServerConfig.model_validate( + { + "listen": "127.0.0.1:9443", + "tls_certificate": directory / "unused.crt", + "tls_private_key": directory / "unused.key", + "gateway_public_key": directory / "public.pem", + "gateway_issuer": "openshell-gateway:fixture", + "gateway_audience": AUDIENCE, + "middleware_name": "pi-egress", + "bearer_token": "test-admission-credential", + "sandbox_id_file": directory / "sandbox-id", + "provider_target": { + "scheme": "https", + "host": "provider.test", + "port": 443, + "method": "POST", + "path": "/v1/chat/completions", + "query": "", + }, + "policy": policy, + } + ) + token = jwt.encode( + { + "iss": config.gateway_issuer, + "aud": AUDIENCE, + "iat": int(time.time()), + "exp": int(time.time()) + 60, + "caller_kind": "supervisor", + "sandbox_id": "sandbox", + }, + key, + algorithm="EdDSA", + headers={"typ": "openshell-ext+jwt"}, + ) + middleware = EgressGateMiddleware( + create_builtin_registry(), + require_agent_attestation=True, + expected_audience=AUDIENCE, + ) + server = _create_grpc_server( + middleware, GatewayAuthentication(public, config.gateway_issuer, AUDIENCE) + ) + port = server.add_insecure_port("127.0.0.1:0") + await server.start() + channel = grpc.aio.insecure_channel(f"127.0.0.1:{port}") + async with TestClient( + TestServer(create_admission_application(middleware, config)) + ) as client: + try: + yield ( + client, + rpc.SupervisorMiddlewareStub(channel), + config, + token, + middleware, + ) + finally: + await channel.close() + await server.stop(0) + await middleware.close() + + +def _call(text: str, *, kind: str = "user_message") -> dict[str, object]: + body: dict[str, object] = { + "schema_version": "openshell.pi-message.v1", + "origin": "user", + "text": text, + } + if kind == "provider_context": + body = { + "schema_version": "openshell.pi-provider-context.v1", + "entries": [{"role": "user", "text": text}], + } + return { + "kind": kind, + "session_id": "session", + "submission_id": "submission", + "body": body, + } + + +def _network(config: AdmissionServerConfig, receipt: str) -> pb.HttpRequestEvaluation: + fixtures = json.loads( + (PROJECT / "tests/admission/fixtures/pi-openai-completions.json").read_text() + ) + body = fixtures["user_request"] + body["messages"][1]["content"] = "safe" + request = pb.HttpRequestEvaluation( + phase=pb.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, + context=pb.RequestContext(sandbox_id="sandbox", request_id="network"), + target=pb.HttpRequestTarget(**config.provider_target.model_dump()), + middleware_name="pi-egress", + body=json.dumps(body).encode(), + headers=[ + pb.HttpHeader(name="content-type", value="application/json"), + pb.HttpHeader(name=RECEIPT_HEADER, value=receipt), + ], + ) + json_format.ParseDict(config.policy, request.config) + return request diff --git a/projects/egress-gate/tests/service/test_server.py b/projects/egress-gate/tests/service/test_server.py index 9578f4bd..ac64a986 100644 --- a/projects/egress-gate/tests/service/test_server.py +++ b/projects/egress-gate/tests/service/test_server.py @@ -161,7 +161,7 @@ async def record_close(middleware: EgressGateMiddleware) -> None: closed.append(middleware) server = EgressGateServer(create_builtin_registry()) - monkeypatch.setattr(server_module, "_create_grpc_server", lambda _: fake_server) + monkeypatch.setattr(server_module, "_create_grpc_server", lambda *args: fake_server) monkeypatch.setattr(EgressGateMiddleware, "close", record_close) await server.serve_async("127.0.0.1:50053") @@ -184,7 +184,7 @@ async def record_close(middleware: EgressGateMiddleware) -> None: closed.append(middleware) server = EgressGateServer(create_builtin_registry()) - monkeypatch.setattr(server_module, "_create_grpc_server", lambda _: fake_server) + monkeypatch.setattr(server_module, "_create_grpc_server", lambda *args: fake_server) monkeypatch.setattr(EgressGateMiddleware, "close", record_close) with pytest.raises(EgressGateError) as error: diff --git a/projects/egress-gate/tests/service/test_servicer.py b/projects/egress-gate/tests/service/test_servicer.py index 22cfe86d..f812889f 100644 --- a/projects/egress-gate/tests/service/test_servicer.py +++ b/projects/egress-gate/tests/service/test_servicer.py @@ -536,12 +536,16 @@ def record_serialization( @pytest.mark.asyncio @pytest.mark.parametrize( - ("action_kind", "expected_source"), - (("detect", "pipeline_default"), ("deny", "gate")), + ("action_kind", "expected_source", "expected_reason"), + ( + ("detect", "pipeline_default", None), + ("deny", "gate", "egress_gate_regex_denied"), + ), ) async def test_evaluation_log_records_decision_source( action_kind: str, expected_source: str, + expected_reason: str | None, caplog: pytest.LogCaptureFixture, ) -> None: middleware = EgressGateMiddleware(create_builtin_registry()) @@ -559,6 +563,7 @@ async def test_evaluation_log_records_decision_source( if item.message.startswith("egress_gate_evaluation") ) assert getattr(record, "decision_source_kind", None) == expected_source + assert getattr(record, "reason_code", None) == expected_reason @pytest.mark.asyncio diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index 7ee04eec..047e2b09 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -74,8 +74,9 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float, + admission: object = None, ) -> None: - del registry + del registry, admission self.timeout_middleware_processing = timeout_middleware_processing def serve_sync(self, listen: str) -> None: @@ -102,6 +103,8 @@ def serve_sync(self, listen: str) -> None: assert "s for seconds or ms for milliseconds" in serve_help assert "Minimum 10ms" in serve_help assert "RPC timeout" in serve_help + assert "--admission-config" in serve_help + assert "--require-" + "pi-attestation" not in serve_help evaluate_help = CliRunner().invoke(app, ["evaluate", "--help"]) assert evaluate_help.exit_code == 0, evaluate_help.output @@ -288,6 +291,17 @@ def test_openshell_example_policies_use_valid_gate_configuration( assert embedded_config == standalone_config +def test_pi_admission_policy_uses_valid_gate_configuration() -> None: + project_dir = Path(__file__).parents[1] + policy_path = project_dir / "examples/pi-attested-admission/policy.yaml" + policy = yaml.safe_load(policy_path.read_text()) + middleware = policy["network_middlewares"]["pi_egress_gate"] + + assert middleware["middleware"] == "pi-egress" + assert len(middleware["middleware"]) <= MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES + create_builtin_registry().validate_config(middleware["config"]) + + @pytest.mark.parametrize( ("example_directory", "name"), [ @@ -535,8 +549,9 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float, + admission: object = None, ) -> None: - del registry, timeout_middleware_processing + del registry, timeout_middleware_processing, admission def serve_sync(self, listen: str) -> None: calls.append(listen) diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py new file mode 100644 index 00000000..09d3628d --- /dev/null +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -0,0 +1,595 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import ipaddress +import json +import os +import runpy +import shlex +import shutil +import ssl +import subprocess +import sys +import threading +import tomllib +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest +import yaml +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from jwt.algorithms import OKPAlgorithm + +from egress_gate.service.admission import AdmissionServerConfig + +PROJECT = Path(__file__).parents[1] +EXAMPLE = PROJECT / "examples/pi-attested-admission" + + +@pytest.fixture +def gateway_discovery( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> Iterator[tuple[dict[str, str], bytes]]: + """Real mTLS with OpenShell-style certs (no AKI or CA Key Usage extension).""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + tls = tmp_path / "config/openshell/gateways/test-gateway/mtls" + tls.mkdir(parents=True) + ca_key = ec.generate_private_key(ec.SECP256R1()) + ca_name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "Test CA")]) + now = datetime.now(UTC) + for name in ("ca", "tls"): + key = ca_key if name == "ca" else ec.generate_private_key(ec.SECP256R1()) + certificate = ( + x509.CertificateBuilder() + .subject_name( + ca_name + if name == "ca" + else x509.Name( + [x509.NameAttribute(x509.NameOID.COMMON_NAME, "test-gateway")] + ) + ) + .issuer_name(ca_name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=1)) + .not_valid_after(now + timedelta(days=1)) + .add_extension( + x509.BasicConstraints(ca=name == "ca", path_length=None), critical=True + ) + .add_extension( + x509.SubjectAlternativeName( + [ + x509.IPAddress(ipaddress.ip_address("127.0.0.1")), + ] + ), + critical=False, + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(key.public_key()), + critical=False, + ) + .sign(ca_key, hashes.SHA256()) + ) + (tls / f"{name}.crt").write_bytes( + certificate.public_bytes(serialization.Encoding.PEM) + ) + if name == "tls": + (tls / "tls.key").write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + signing_key = Ed25519PrivateKey.generate().public_key() + gateway = {"name": "test-gateway", "auth": "mtls"} + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + if self.path == "/.well-known/openid-configuration": + body = { + "issuer": "existing-gateway-issuer", + "jwks_uri": gateway.get( + "jwks_uri", gateway["endpoint"] + "/.well-known/jwks.json" + ), + } + else: + assert self.path == "/.well-known/jwks.json" + body = {"keys": [json.loads(OKPAlgorithm.to_jwk(signing_key))]} + self.send_response(200) + self.end_headers() + self.wfile.write(json.dumps(body).encode()) + + def log_message(self, format: str, *args: object) -> None: + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(tls / "tls.crt", tls / "tls.key") + context.load_verify_locations(tls / "ca.crt") + context.verify_mode = ssl.CERT_REQUIRED + server.socket = context.wrap_socket(server.socket, server_side=True) + gateway["endpoint"] = f"https://127.0.0.1:{server.server_port}" + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield ( + gateway, + signing_key.public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ), + ) + finally: + server.shutdown() + server.server_close() + thread.join() + + +def test_every_action_prints_without_secrets_or_side_effects(tmp_path: Path) -> None: + example = tmp_path / "examples/demo" + example.mkdir(parents=True) + script = example / "demo.sh" + shutil.copyfile(EXAMPLE / "demo.sh", script) + marker = tmp_path / "must-not-exist" + (example / ".env").write_text( + f"touch {marker}\nPI_MODEL_API_KEY=PRIVATE_TEST_VALUE\n" + ) + output = "" + for action in ( + "prepare", + "serve", + "registration", + "register", + "unregister", + "setup", + "launch", + "verify", + "cleanup", + ): + result = subprocess.run( + ["bash", str(script), "--print", action], + check=True, + capture_output=True, + text=True, + env=os.environ + | { + "PI_MODEL_API_KEY": "PRIVATE_TEST_VALUE", + "OPENSHELL_GATEWAY": "test-gateway", + "EGRESS_GATE_HOST": "service.example", + }, + ) + output += result.stdout + assert result.stderr == "" + assert "PRIVATE_TEST_VALUE" not in output + assert not marker.exists() + assert not (tmp_path / ".workspaces").exists() + + +@pytest.mark.parametrize( + ("failure", "expected_status"), + [ + ("", 0), + ( + "code: 'Some requested entity was not found', " + 'message: "sandbox not found"', + 0, + ), + ("code: 'Permission denied', message: \"sandbox not found\"", 7), + ( + "code: 'Some requested entity was not found', " + 'message: "workspace not found"', + 7, + ), + ("Connection refused", 7), + ], +) +def test_cleanup_after_partial_setup( + tmp_path: Path, failure: str, expected_status: int +) -> None: + example = tmp_path / "examples/demo" + example.mkdir(parents=True) + script = example / "demo.sh" + shutil.copyfile(EXAMPLE / "demo.sh", script) + commands = tmp_path / "commands" + binaries = tmp_path / "bin" + binaries.mkdir() + # Exercise the real shell flow without deleting a live sandbox or registration. + stub = f"""#!{sys.executable} +import os, pathlib, sys +with open(os.environ["COMMAND_LOG"], "a") as log: + log.write(pathlib.Path(sys.argv[0]).name + " " + " ".join(sys.argv[1:]) + "\\n") +if "sandbox" in sys.argv and os.environ["SANDBOX_FAILURE"]: + print(os.environ["SANDBOX_FAILURE"], file=sys.stderr) + sys.exit(7) +""" + for name in ("openshell", "uv"): + executable = binaries / name + executable.write_text(stub) + executable.chmod(0o755) + result = subprocess.run( + ["bash", str(script), "cleanup"], + capture_output=True, + text=True, + env=os.environ + | { + "PATH": f"{binaries}{os.pathsep}{os.environ['PATH']}", + "OPENSHELL_GATEWAY": "test-gateway", + "COMMAND_LOG": str(commands), + "SANDBOX_FAILURE": failure, + }, + ) + assert result.returncode == expected_status + recorded = commands.read_text().splitlines() + assert recorded[0] == "openshell --gateway test-gateway sandbox delete pi-admission" + if expected_status: + assert len(recorded) == 1 + assert failure in result.stderr + assert "sessions removed" not in result.stdout + else: + assert len(recorded) == 7 + assert "provider delete pi-admission-model" in recorded[1] + assert "provider profile delete pi-admission-admission" in recorded[4] + assert "gateway-registration.py unregister" in recorded[-1] + if failure: + assert "already absent; continuing cleanup" in result.stdout + + +@pytest.mark.parametrize( + "installation", ["homebrew-prefix", "homebrew-user", "systemd", "systemd-defaults"] +) +def test_installer_registration_round_trip( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, installation: str +) -> None: + configure = runpy.run_path(str(EXAMPLE / "gateway-registration.py"))["configure"] + homebrew = installation.startswith("homebrew") + monkeypatch.setattr(sys, "platform", "darwin" if homebrew else "linux") + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.delenv("OPENSHELL_GATEWAY_CONFIG", raising=False) + prefix_config = tmp_path / "brew/var/openshell/gateway.toml" + prefix_config.parent.mkdir(parents=True) + original = ( + "# Keep my comments\n[openshell]\nversion = 1\n" + '[openshell.gateway]\nbind_address = "127.0.0.1:17670"\n' + '[[openshell.supervisor.middleware]]\nname = "other"\n' + 'grpc_endpoint = "http://localhost:1234"\n' + ) + prefix_config.write_text(original) + config = prefix_config + if installation != "homebrew-prefix": + config = tmp_path / "config/openshell/gateway.toml" + config.parent.mkdir(parents=True) + config.write_text(original) + if installation == "systemd-defaults": + config.unlink() + original = "[openshell]\nversion = 1\n" + state = tmp_path / "state" + state.mkdir() + fragment = ( + '[[openshell.supervisor.middleware]]\nname = "pi-egress"\n' + 'grpc_endpoint = "https://service.example:50051"\n' + 'tls_ca_cert_path = "/demo/tls/ca.crt"\n' + 'audience = "urn:openshell:extension:middleware:pi-egress"\n' + 'max_payload_bytes = 4194304\ntimeout = "10s"\n' + ) + (state / "middleware.toml").write_text(fragment) + commands: list[tuple[str, ...]] = [] + endpoint = "https://localhost:17670" + fail_restart = False + restart = ( + ["brew", "services", "restart", "openshell"] + if homebrew + else ["systemctl", "--user", "restart", "openshell-gateway"] + ) + + def output(command: tuple[str, ...], **_kwargs: object) -> str: + commands.append(command) + if command == ("brew", "--prefix"): + assert homebrew + return str(tmp_path / "brew") + if command == ( + "systemctl", + "--user", + "show", + "openshell-gateway", + "--property=LoadState", + "--value", + ): + assert not homebrew + return "loaded\n" + assert command == ("openshell", "gateway", "list", "--output", "json") + return json.dumps([{"name": "openshell", "endpoint": endpoint}]) + + def run(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + commands.append(tuple(command)) + if command == restart: + if fail_restart: + raise subprocess.CalledProcessError(1, command) + return subprocess.CompletedProcess(command, 0) + assert command == [ + "openshell", + "--gateway", + "openshell", + "gateway", + "info", + "--output", + "json", + ] + return subprocess.CompletedProcess(command, 0, '{"status":"healthy"}') + + monkeypatch.setattr(subprocess, "check_output", output) + monkeypatch.setattr(subprocess, "run", run) + # Never modify a local service when the selected gateway is remote. + endpoint = "https://remote.example:17670" + with pytest.raises(ValueError, match="local installer-managed gateway"): + configure("register", state, "openshell") + if installation == "systemd-defaults": + assert not config.exists() + else: + assert config.read_text() == original + endpoint = "https://localhost:17670" + # Refuse to take over an operator's pre-existing registration, even if identical. + config.write_text(original + fragment) + with pytest.raises(ValueError, match="refusing to overwrite"): + configure("register", state, "openshell") + config.write_text(original) + if installation == "systemd-defaults": + config.unlink() + fail_restart = True + with pytest.raises(subprocess.CalledProcessError): + configure("register", state, "openshell") + assert (state / "gateway-registration.json").exists() + fail_restart = False + configure("register", state, "openshell") + registered = config.read_text() + assert registered.startswith(original) + assert registered.count('name = "pi-egress"') == 1 + assert ( + tomllib.loads(registered)["openshell"]["supervisor"]["middleware"][-1] + == (tomllib.loads(fragment)["openshell"]["supervisor"]["middleware"][0]) + ) + changed = registered.replace( + "https://service.example:50051", "https://operator.example:50051" + ) + config.write_text(changed) + before = len(commands) + with pytest.raises(ValueError, match="registration changed"): + configure("unregister", state, "openshell") + assert config.read_text() == changed + assert tuple(restart) not in commands[before:] + config.write_text(registered) + fail_restart = True + with pytest.raises(subprocess.CalledProcessError): + configure("unregister", state, "openshell") + assert (state / "gateway-registration.json").exists() + fail_restart = False + configure("unregister", state, "openshell") + assert config.read_text().strip() == original.strip() + assert not (state / "gateway-registration.json").exists() + before = len(commands) + configure("unregister", state, "openshell") + assert len(commands) == before + if installation == "homebrew-user": + assert prefix_config.read_text() == original + + +def test_prepare_requires_operator_model_configuration(tmp_path: Path) -> None: + script = tmp_path / "demo.sh" + shutil.copyfile(EXAMPLE / "demo.sh", script) + result = subprocess.run( + ["bash", str(script), "prepare"], + env=os.environ | {"OPENSHELL_GATEWAY": "test", "EGRESS_GATE_HOST": "localhost"}, + capture_output=True, + text=True, + ) + assert result.returncode == 1 + assert "Create models.json from models.json.example" in result.stderr + assert not (tmp_path / "models.json").exists() + + +@pytest.mark.parametrize("action", ["launch", "verify"]) +@pytest.mark.parametrize("cache_retention", ["", "long"]) +def test_commands_forward_cache_preference_and_suppress_only_proxy_warning( + action: str, cache_retention: str +) -> None: + printed = subprocess.run( + ["bash", str(EXAMPLE / "demo.sh"), "--print", action], + capture_output=True, + text=True, + check=True, + env=os.environ | {"PI_CACHE_RETENTION": cache_retention}, + ) + command = shlex.split(printed.stdout) + node_index = command.index("/usr/local/bin/node") + assert command[node_index + 1] == "--disable-warning=UNDICI-EHPA" + prefix = command[command.index("--") + 1 : node_index] + expected = ["/usr/bin/env"] + if cache_retention: + expected.append(f"PI_CACHE_RETENTION={cache_retention}") + assert prefix == expected + + +@pytest.mark.parametrize("host", ["192.0.2.10", "host.docker.internal"]) +def test_preparation_uses_existing_gateway_and_excludes_private_material( + tmp_path: Path, + host: str, + gateway_discovery: tuple[dict[str, str], bytes], +) -> None: + state = tmp_path / "state" + example = tmp_path / "example" + shutil.copytree( + EXAMPLE, + example, + ignore=shutil.ignore_patterns( + ".env", "model.json", "models.json", "node_modules", "dist" + ), + ) + catalog = json.loads((example / "models.json.example").read_text()) + provider = catalog["providers"]["openrouter"] + provider["apiKey"] = "must-not-enter-image" + provider["models"][0]["baseUrl"] = provider["baseUrl"] + provider["modelOverrides"] = {"z-ai/glm-5.3-flash": {"maxTokens": 2048}} + provider["baseUrl"] = "https://unselected.example/v1" + provider["models"].append({"id": "unselected"}) + (example / "models.json").write_text(json.dumps(catalog)) + gateway, public = gateway_discovery + public_path = state / "gateway-public.pem" + command = [ + sys.executable, + str(example / "prepare.py"), + "--state", + str(state), + "--host", + host, + "--gateway", + gateway["name"], + "--model", + "openrouter/z-ai/glm-5.3-flash", + ] + subprocess.run(command, input=json.dumps([gateway]), text=True, check=True) + config = AdmissionServerConfig.model_validate_json( + (state / "admission.json").read_bytes() + ) + assert config.provider_target.scheme == "https" + assert config.provider_target.host == "openrouter.ai" + assert config.gateway_public_key == public_path + assert config.gateway_issuer == "existing-gateway-issuer" + assert public_path.read_bytes() == public + assert not (state / "tls/jwt").exists() + assert not (state / "tls/client").exists() + assert not (state / "gateway.toml").exists() + certificate_bytes = (state / "tls/server/tls.crt").read_bytes() + certificate = x509.load_pem_x509_certificate(certificate_bytes) + names = certificate.extensions.get_extension_for_class( + x509.SubjectAlternativeName + ).value + assert host in [str(name.value) for name in names] + assert config.provider_target.path == "/api/v1/chat/completions" + assert not config.sandbox_id_file.exists() + subprocess.run( + [sys.executable, str(example / "bind-sandbox.py"), "--state", str(state)], + input=json.dumps({"id": "actual-sandbox-id"}), + text=True, + check=True, + ) + assert config.sandbox_id_file.read_text().strip() == "actual-sandbox-id" + token = config.bearer_token.get_secret_value() + assert len(token) >= 32 + (state / "image/stale-config.json").write_text("{}") + subprocess.run(command, input=json.dumps([gateway]), text=True, check=True) + again = AdmissionServerConfig.model_validate_json( + (state / "admission.json").read_bytes() + ) + assert again.bearer_token == config.bearer_token + assert (state / "tls/server/tls.crt").read_bytes() == certificate_bytes + for name in ("model", "admission"): + profile = yaml.safe_load((state / f"{name}-provider.yaml").read_text()) + credential = profile["credentials"][0] + assert set(credential) == {"name", "env_vars", "required"} + assert token not in json.dumps(profile) + image = state / "image" + assert not (image / "stale-config.json").exists() + assert not list(image.rglob("*.key")) + assert not list(image.rglob("*.pem")) + assert not list(image.rglob(".env")) + assert not (image / "admission.json").exists() + assert not (image / "pi-harness/node_modules").exists() + assert (image / "project/.pi/skills/review/SKILL.md").is_file() + catalog = json.loads((image / "models.json").read_text()) + assert catalog["providers"]["openrouter"]["models"][0]["id"] == "z-ai/glm-5.3-flash" + assert len(catalog["providers"]["openrouter"]["models"]) == 1 + assert ( + catalog["providers"]["openrouter"]["modelOverrides"] + == provider["modelOverrides"] + ) + assert "must-not-enter-image" not in (image / "models.json").read_text() + assert json.loads((image / "model-selection.json").read_text()) == { + "provider": "openrouter", + "id": "z-ai/glm-5.3-flash", + } + middleware = tomllib.loads((state / "middleware.toml").read_text()) + registration = middleware["openshell"]["supervisor"]["middleware"][0] + assert registration["grpc_endpoint"] == f"https://{host}:50051" + assert registration["tls_ca_cert_path"] == str(state / "tls/ca.crt") + assert "gateway" not in middleware["openshell"] + assert "drivers" not in middleware["openshell"] + policy = yaml.safe_load((state / "policy.yaml").read_text()) + assert policy["network_middlewares"]["pi_egress_gate"]["on_error"] == "fail_closed" + model_endpoint = policy["network_policies"]["model_provider"]["endpoints"][0] + assert model_endpoint["rules"] == [ + {"allow": {"method": "POST", "path": "/api/v1/chat/completions"}} + ] + assert "access" not in model_endpoint + assert policy["network_policies"]["admission"]["endpoints"][0]["port"] == 5443 + assert policy["network_policies"]["admission"]["endpoints"][0]["host"] == host + admission_profile = yaml.safe_load((state / "admission-provider.yaml").read_text()) + assert admission_profile["endpoints"][0]["host"] == host + command[command.index("--host") + 1] = "new-service.example" + subprocess.run(command, input=json.dumps([gateway]), text=True, check=True) + assert (state / "tls/server/tls.crt").read_bytes() != certificate_bytes + assert public_path.read_bytes() == public + + +@pytest.mark.parametrize( + "failure", ["plaintext", "foreign-key-url", "untrusted-ca", "wrong-hostname"] +) +def test_discovery_rejects_untrusted_gateway_before_preparation( + tmp_path: Path, gateway_discovery: tuple[dict[str, str], bytes], failure: str +) -> None: + gateway, _ = gateway_discovery + if failure == "plaintext": + gateway["endpoint"] = gateway["endpoint"].replace("https:", "http:") + elif failure == "foreign-key-url": + gateway["jwks_uri"] = "https://untrusted.example/keys" + elif failure == "wrong-hostname": + gateway["endpoint"] = gateway["endpoint"].replace("127.0.0.1", "localhost") + else: + # Keep the client identity, but remove its trust in the server's CA. + key = Ed25519PrivateKey.generate() + name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "Wrong CA")]) + certificate = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.now(UTC) - timedelta(minutes=1)) + .not_valid_after(datetime.now(UTC) + timedelta(days=1)) + .add_extension( + x509.BasicConstraints(ca=True, path_length=None), critical=True + ) + .sign(key, None) + ) + (tmp_path / "config/openshell/gateways/test-gateway/mtls/ca.crt").write_bytes( + certificate.public_bytes(serialization.Encoding.PEM) + ) + result = subprocess.run( + [ + sys.executable, + str(EXAMPLE / "prepare.py"), + "--state", + str(tmp_path / "state"), + "--host", + "127.0.0.1", + "--gateway", + gateway["name"], + ], + input=json.dumps([gateway]), + text=True, + capture_output=True, + ) + assert result.returncode != 0 + assert { + "plaintext": "registered HTTPS/mTLS gateway", + "foreign-key-url": "same HTTPS origin", + "untrusted-ca": "CERTIFICATE_VERIFY_FAILED", + "wrong-hostname": "Hostname mismatch", + }[failure] in result.stderr + assert not (tmp_path / "state").exists() diff --git a/projects/egress-gate/uv.lock b/projects/egress-gate/uv.lock index 88ae26e6..59845603 100644 --- a/projects/egress-gate/uv.lock +++ b/projects/egress-gate/uv.lock @@ -2,6 +2,146 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -20,6 +160,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "boolean-py" version = "5.0" @@ -56,6 +205,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.9" @@ -139,6 +386,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + [[package]] name = "cyclonedx-python-lib" version = "11.11.0" @@ -169,9 +472,12 @@ name = "egress-gate" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "aiohttp" }, + { name = "cryptography" }, { name = "grpcio" }, { name = "protobuf" }, { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "pyyaml" }, { name = "regex" }, { name = "rich" }, @@ -190,9 +496,12 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiohttp", specifier = ">=3.12,<4" }, + { name = "cryptography", specifier = ">=50,<51" }, { name = "grpcio", specifier = ">=1.81.1,<2" }, { name = "protobuf", specifier = ">=7.36,<8" }, { name = "pydantic", specifier = ">=2.11,<3" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10,<3" }, { name = "pyyaml", specifier = ">=6,<7" }, { name = "regex", specifier = ">=2026.7.19,<2027" }, { name = "rich", specifier = ">=14,<16" }, @@ -218,6 +527,111 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" }, ] +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + [[package]] name = "grpcio" version = "1.82.1" @@ -383,6 +797,164 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, ] +[[package]] +name = "multidict" +version = "6.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/95/989c1b5ca17b72128661530cd6e351a0a83cda9a4d6c036e9ed976c18931/multidict-6.8.0.tar.gz", hash = "sha256:5cd4637ce76312ba1e05eb9c5193fec231f64fee0944e135fa1e951242355b37", size = 122412, upload-time = "2026-09-09T13:57:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/37/90216392620b6ef8704eb0bc055141745de396121067e98f1f72bdac33c3/multidict-6.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b8986d4313dcee7c932837d16a535f1840b827bac1ea7c5c4c80751d0423794", size = 85033, upload-time = "2026-09-09T13:53:18.786Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bb/e01b8cf906479b2fa046e992b84a9d9f39c1ed4058acc353004cd9a04df9/multidict-6.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2622fe114c0bd66ca5c461859357587f5a5e35ee5ff49fc5643d1bc78dbb41c6", size = 51008, upload-time = "2026-09-09T13:53:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/c5/da/35d70c920812d9ddc6f295f6426457665194335fbc35aa0b96716aba219f/multidict-6.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26a7aafc992e78872e2c8c1f7248c0e01139cf9020a7781b0c064fa566832712", size = 50232, upload-time = "2026-09-09T13:53:21.356Z" }, + { url = "https://files.pythonhosted.org/packages/91/6b/4c988a7c0daa4fbffc6080ed3c37b3a67cf225ba1de69d10a19ca1dd8d0d/multidict-6.8.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:05c2e90c5289c5f7436ba2c25812a5fbdaa1c1bc11c8d8d3bbf64f5cd7c633dd", size = 271678, upload-time = "2026-09-09T13:53:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/2b/22c7de8a72fc5c36390e8049d86d842b032ac7c87ade035a3dafb7df4ffc/multidict-6.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7941ef106ca1f2c62314a13c7ed913bcf49641f3efdc12864d588e17870920ac", size = 270283, upload-time = "2026-09-09T13:53:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/c1428318f945c57c016ba690338af41f87f18a7d3a7ef3227b1440a2c169/multidict-6.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a5a7ee1217949ddd43c6b7bcf70d5c22193bb50e8c695386de5905325e93ce9f", size = 245306, upload-time = "2026-09-09T13:53:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/8b/92/a37f7519fb32b0bf43b0540292effe60edaf0691959214b227795bd3d56a/multidict-6.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b62b7e0025aa48dec11e125e655d1157985a5fdcec04b1ad500101ad072b891", size = 279567, upload-time = "2026-09-09T13:53:27.162Z" }, + { url = "https://files.pythonhosted.org/packages/51/fe/a93c2ce417401863cc88ecf6561577625c140990d412e37f347a1c03a144/multidict-6.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea880d441be7c510106bc56064be39266d948aef94ad4955e8784690019a5d9f", size = 282526, upload-time = "2026-09-09T13:53:28.927Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9d/6bb4f84fdd82acfa09dc312ac133e7f76cbf2370004447f2a90e65e5d63f/multidict-6.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab83fdd8cf307353edba9c427c17a3a021c2522d690f5633dd9f72d28b48ccca", size = 272604, upload-time = "2026-09-09T13:53:30.595Z" }, + { url = "https://files.pythonhosted.org/packages/3a/97/df0a30a4d786d313f24b39cb96edaaa3bbfe83a0b309577c81a797783ec5/multidict-6.8.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3d1f48582686a0a3b81e9b43234766cc96697df72081af3f48107bd3f34d34e5", size = 250786, upload-time = "2026-09-09T13:53:32.15Z" }, + { url = "https://files.pythonhosted.org/packages/6f/72/e59a917680d00214ba41f9fec19a8bec48f3bdf62656bc4377f37ae30947/multidict-6.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:563661919f603374c40cf45ffcd25535c12b8954203569a2ab1cee5265871cf4", size = 265419, upload-time = "2026-09-09T13:53:33.943Z" }, + { url = "https://files.pythonhosted.org/packages/47/21/0eb8868982ff07c1a2faaef7502ee0be32ef247dc1bf27881c51e7f4b20d/multidict-6.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2ba9933e8f35fe4a70f540b837254c4055da82dc3a9e500a8f95e61498083a15", size = 258934, upload-time = "2026-09-09T13:53:35.662Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a6/0586396716faf950c10ffbe733e4a57b4eeda9f7073e60c09bd4a05a766e/multidict-6.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:68d40b2bace413f3231f5729d3fcfb1837fd31c4907e241b5d43211bfd76f3c2", size = 273168, upload-time = "2026-09-09T13:53:37.131Z" }, + { url = "https://files.pythonhosted.org/packages/31/79/7197af20190d0d832be3b18582be46c224f64f5ba1cd35d32068d6af31ed/multidict-6.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a9e246f67ac038568b854ed7c5578e4c6af1f742359901a8fcc3603ff1358df6", size = 275884, upload-time = "2026-09-09T13:53:38.579Z" }, + { url = "https://files.pythonhosted.org/packages/f7/f7/af60573e25ffecc09e805464580d579338cf1a44332ab52757038435ed60/multidict-6.8.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:29b6e7bc4442a56cf8e0dc1cabf3fdc77cd533568d6829fc76a1effd2ce332ec", size = 246967, upload-time = "2026-09-09T13:53:40.172Z" }, + { url = "https://files.pythonhosted.org/packages/6b/02/4459f8c5025ab034d3d9af9a34bbde11319016cff2f327362c8ec43a80a1/multidict-6.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:82780eb8bf59e8fb25dd081fde6e058805045d6374a7f2f877effc826ca4434b", size = 272358, upload-time = "2026-09-09T13:53:41.868Z" }, + { url = "https://files.pythonhosted.org/packages/51/99/680d3522ab51a77094d31d7958c9f5989499a01ffbe5aebe11d25212b385/multidict-6.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2196ba6df392c3574acadd14ef87550f3611349c8618564de324b806a7a31cee", size = 267450, upload-time = "2026-09-09T13:53:43.646Z" }, + { url = "https://files.pythonhosted.org/packages/4a/04/d0c773805b0aea171287b01a82e4c28c59ef2c2d93e8047394765181363f/multidict-6.8.0-cp311-cp311-win32.whl", hash = "sha256:b8b7aa75146266fd3e2a2437cf69ae188688c04ab8665b163d4257b46c1e0c83", size = 46847, upload-time = "2026-09-09T13:53:45.161Z" }, + { url = "https://files.pythonhosted.org/packages/71/f8/1a959771a4dcd3224bd7bb40054f66b98ba5b20d6b74fd273f548f887e0a/multidict-6.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:b03ca066b47b18b205cc080dca6f76cbd159f8cdd33a02a0700164c13b37e463", size = 51549, upload-time = "2026-09-09T13:53:46.448Z" }, + { url = "https://files.pythonhosted.org/packages/64/7c/3a74b11599a9d8f3cfbb78b9c5cac3ff3cdc17278e4c00329c7c18dcaff9/multidict-6.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:54af1266710cb0f305127ae0b970aff8d208057f8a29cd6e1db99b0114947035", size = 48020, upload-time = "2026-09-09T13:53:47.767Z" }, + { url = "https://files.pythonhosted.org/packages/13/83/a4621577679149ea001806f5963f3fc687c391c1bd5217157be2278863f5/multidict-6.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:22a310ad37672a261e55a8b5e28d0ae08cfb68abb1f46418ccd19835c3b8e836", size = 84146, upload-time = "2026-09-09T13:53:49.163Z" }, + { url = "https://files.pythonhosted.org/packages/09/00/236b063f3e606055a3a9ba8faa5d40e6c688b059a58056b055f213476f46/multidict-6.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bb8c7da8c861391f7ae48e3593762be2dabe405109e01aec520fbe1a6d15d14b", size = 51049, upload-time = "2026-09-09T13:53:50.46Z" }, + { url = "https://files.pythonhosted.org/packages/91/9d/954b139bfa969855f2d4cb5ae7b7d44dd7106f754305b6e21a9068213aa7/multidict-6.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10456943903744ae1249728161c96bd9d2f7eb5ee17fcc2ffda2dc32e1bb36c7", size = 49362, upload-time = "2026-09-09T13:53:51.878Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/8774f5b3f6d5266ecd1117876e04b405f0f1ce19aa750b35a826efe6cfe4/multidict-6.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:658f5a1895b804423d97b22d06fc0d0b171c7c01dcc3aa9c8faf0c0e26a249a5", size = 278619, upload-time = "2026-09-09T13:53:53.44Z" }, + { url = "https://files.pythonhosted.org/packages/db/47/736080fec911ed9f2dd57ccab5a8145e4f17c4987de0bfc27bee20e4d170/multidict-6.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c10b22860dbd09982d0b8993b66231a861bea2993d4a817ff35273f6ea285a", size = 283771, upload-time = "2026-09-09T13:53:55.048Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d5/b7f41f59b0583f092602308a5e7c16ec5efd00d60214b22511e89a38dd19/multidict-6.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:088b04a66b3c1fce6fe4d771ec184a0426262d0b86709c908477b4ac7965df40", size = 262108, upload-time = "2026-09-09T13:53:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/a52dc06c6e2598672308e3d392fd85b837b23c25dda459bedaea84985080/multidict-6.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9caef53b20a105c0d66518a34be2f71b2783de8d091767575ef86f6ea422236d", size = 289899, upload-time = "2026-09-09T13:53:58.415Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/00cda7983f37d119b86f1f89d5b4cf771ecb6d0fedeb9a0971758d6d6d4a/multidict-6.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a5e1583c14775580da05641240ce0d93f36ce3ddef3d5083a827468b0bcfe874", size = 293025, upload-time = "2026-09-09T13:53:59.973Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c7/4544cc02e45bbfac4d8788b05379bb360021fd8c53fa74b0f624126ac188/multidict-6.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:003a3bddb32915c3f67096ea41d24e53edf710edb65a1f5d0c70ab40b0e4d20b", size = 287410, upload-time = "2026-09-09T13:54:01.652Z" }, + { url = "https://files.pythonhosted.org/packages/43/1a/7abed90b8eba381842235bfa6f4d730204fd7deb374fc87e3ec9b2c2b4ac/multidict-6.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35977263d9bf506dbc65349f63b3b8c91606d4abc110990945e3b94bc671319c", size = 255878, upload-time = "2026-09-09T13:54:03.366Z" }, + { url = "https://files.pythonhosted.org/packages/25/3e/73fae10e15fc4d711975337caff7e494c87de5d0189afe3518b21b945326/multidict-6.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9dc7b4ff6ef184504b49ef9a4113d49a646653b2ce89f5f48c1f57cdf6ba081", size = 277831, upload-time = "2026-09-09T13:54:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/c5/cf/01cfc81492933331147004861bdff201d8adeba8485ecd8f490e755fe7e8/multidict-6.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:887f9a975996032c686719eb7b3e1e7942fab5079c2b778bbd9afe9a9d78244f", size = 275096, upload-time = "2026-09-09T13:54:06.661Z" }, + { url = "https://files.pythonhosted.org/packages/de/59/e9a3773b17297fa1e38fd4b3c6f5f2f458380796be62eca7d0d77c250618/multidict-6.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f3071e6515cc63714d014da8f738ae9fa3997c476203f3cd46de380c2376ed7b", size = 279803, upload-time = "2026-09-09T13:54:08.389Z" }, + { url = "https://files.pythonhosted.org/packages/64/9d/2d712a2605b3971908e3b4f5eb6f98c353d9991e106f684d0e08ae581814/multidict-6.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5f3a2af441670d80ce5fdf13b6c1b421fc1fc7fc5182d58ac7486738bb2b742", size = 284595, upload-time = "2026-09-09T13:54:10.17Z" }, + { url = "https://files.pythonhosted.org/packages/58/6c/21aded8586e552b29892268c576e5745d1a894c5451c9866ca3c06b7ec50/multidict-6.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:31e8901637e20ccb3cf8f8848b5d0f7a00462bf5b34f7cf3dcbb2753b18e8b39", size = 252641, upload-time = "2026-09-09T13:54:11.811Z" }, + { url = "https://files.pythonhosted.org/packages/2a/70/56a415ae0a45e5eae2ec817d46aeb72a1ae777863621c85f1f39d329275b/multidict-6.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:13967dca8b2f33230a1427b52438326bb1c9101a1df22a3309ed3fcbbb3c96f0", size = 283369, upload-time = "2026-09-09T13:54:13.59Z" }, + { url = "https://files.pythonhosted.org/packages/08/7e/7b7cd611fd94bf2f6bd16244c50495867ba394d5baaf8e6e487d39494ab3/multidict-6.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad474c11d851b6fc97cb625e4822bc0cbd567fc07dc2602e28faec5a36b42bbb", size = 281653, upload-time = "2026-09-09T13:54:15.174Z" }, + { url = "https://files.pythonhosted.org/packages/33/4a/b19a5892ef2ef6c68ae278b4f1504b82e01037baedd92c55d37e55ecad00/multidict-6.8.0-cp312-cp312-win32.whl", hash = "sha256:7bb0dad75068fee80fcb60f88569722c199d8656a16706702dc6e3b786819c90", size = 47936, upload-time = "2026-09-09T13:54:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/29/00/1952f9f282aa71e7c3db3a6b47afb689d0ddf283dbded7e6326a91d421c9/multidict-6.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:7d26dc8f070c0ec5579e987fa615ffd6883086106eefdff9e10d160fc5630630", size = 51723, upload-time = "2026-09-09T13:54:18.05Z" }, + { url = "https://files.pythonhosted.org/packages/49/b5/c9d57dbafe25b8f3460ce2961c968539a81ff7a70160c44dcfd4255cbcd1/multidict-6.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:e6ec7d37841609a691b96a10b4fde386c7cd93ebbb939f59c9f23325ee788395", size = 48492, upload-time = "2026-09-09T13:54:19.42Z" }, + { url = "https://files.pythonhosted.org/packages/84/1f/d7112c2dd7db02677097be72fb65542f51a5aa73cb472b87ec211ba9e0dd/multidict-6.8.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:ec0a4d066356054d569a66e0a94691a2058b680be5e710298f61db11a3c4609f", size = 54197, upload-time = "2026-09-09T13:54:20.814Z" }, + { url = "https://files.pythonhosted.org/packages/ae/24/876015abbcb4a179d946579eb77b778eb5a948fc8381bc7928ba895bc051/multidict-6.8.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:714597cb5d5e15a8a449d2ae23c45b486a9e8fa33c462c7a33d7f35b65d92943", size = 47787, upload-time = "2026-09-09T13:54:22.51Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/ceb7d25f8a567599db2eb19b08cac58d67ff553cff42dcadbea9aba56a20/multidict-6.8.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e0db3a4d1e264e225037a6023888972c25206a96e016021a5bea41c9a939f2a9", size = 48815, upload-time = "2026-09-09T13:54:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/18/e3/e1c6e9c3818c34b782f23ce5fdba3eaa34ec6750dc53078dfac80fa59be7/multidict-6.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:27747162712e85c84598d364425dbf1714ff335bdb6ba3171c4e5081196e8916", size = 83484, upload-time = "2026-09-09T13:54:25.674Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a0/c23f78a4badee9a5b3e760495c661c62a92c340a1dfd00f829cd16e256bb/multidict-6.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:847d6082ae694dc95e548acb201bc100e1cfa96513bc71fdcb86f709dad6c435", size = 50763, upload-time = "2026-09-09T13:54:27.135Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/db552d402a3f6b650f5d3ae11b82b93833836aebb51bcda22d8691121129/multidict-6.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:88a6df88567680504ae28bfa7a1f2f64243d91e79a40b2c92ef42efc531e23da", size = 49029, upload-time = "2026-09-09T13:54:28.483Z" }, + { url = "https://files.pythonhosted.org/packages/01/b4/546853fba19dcef77cdf91fc173faf0b02284a49106cf250511166b4ec5c/multidict-6.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:560b211fc3bd4a1e1c6de44f6d38113bf5b410dfc89a4c0d2a3c0edbf1a0dfb8", size = 278863, upload-time = "2026-09-09T13:54:30.145Z" }, + { url = "https://files.pythonhosted.org/packages/ee/3f/4b52dac7db547936eb762123ac1d99df23f92fdb358bae600e322f611247/multidict-6.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202436df907c15adbb94360296c425ea53cf8968a5d2cff9b5b9790ae1972b33", size = 283915, upload-time = "2026-09-09T13:54:31.937Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6e/c0dfbf170e49a91bcb9ce850d51cb98357f3033c5227529200ca7625853e/multidict-6.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c46a08bf070d6849fed483e9d9833f9d06aecb8382ed985be0b38508b3ae958e", size = 260704, upload-time = "2026-09-09T13:54:33.529Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/56973a060ab8dfc2e80bb6797682f6577aff7123cdb1de1a568670ae3499/multidict-6.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cd560498ae8e1bcc955643c1d78eb8e338226d07a983c656ea8c4443d3eec0f", size = 290243, upload-time = "2026-09-09T13:54:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d5/67/69112989f131bdea4a87b74e82cb0a2daf37880cd92b0e6f0420020adceb/multidict-6.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:758233648ac47b07c575224c4eadd73c8929c3b4c31e2afcfea935fde1cda735", size = 291131, upload-time = "2026-09-09T13:54:37.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/75/9435f68b0cfc442d4917de85c26f2b2e1292630883414a25576083fa2469/multidict-6.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:122adc7c46ac1e31ecfc7f81b2530533dccafdba70f5d741649f87e336c63384", size = 287551, upload-time = "2026-09-09T13:54:38.835Z" }, + { url = "https://files.pythonhosted.org/packages/13/08/2ee4838081d6587849611aa7ec722c4cb2469e912fd0eaee980e7bac064c/multidict-6.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8125e60f3c70e323ac07dd8b3635f7b3bbc5c3a9ac04ae5988f668ff7ae28a18", size = 254591, upload-time = "2026-09-09T13:54:40.806Z" }, + { url = "https://files.pythonhosted.org/packages/94/f1/05673b51191f77f4198b8e4b35f16ea71c0300c72ca8aa027a66a61b6edc/multidict-6.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83ff054b04915be5c15680da6c6012474a2cc2bf534129a0e8c6a99f17ba7238", size = 278204, upload-time = "2026-09-09T13:54:42.672Z" }, + { url = "https://files.pythonhosted.org/packages/45/4f/b6cf74322b3fbd3e011a1e903730191922291a7779f6d404114c2189b806/multidict-6.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:930c6058047410e3edff445f5a6e4457f2e089042dede00e2d18ce06f3ceae2e", size = 275600, upload-time = "2026-09-09T13:54:44.348Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ab/958bbb04377159ff03c7314cd9d8a48dd6fc4f78c840589c22ab155ee9c7/multidict-6.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:13e26f59f0eecfc5f67c663ad550ffdaf62c0f657547cde387f6c86af1c9449e", size = 279793, upload-time = "2026-09-09T13:54:46.086Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3a/706605ab0dfc4179748ee7949829e63c6f14ae28667aceeefaf2c701807f/multidict-6.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fd789a294d8e098528be29b2669b83005ce569339f8cef167fc0274c3115c34c", size = 284751, upload-time = "2026-09-09T13:54:47.793Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a5/567e36c013ad023546de633079c6b22101dd43226b193cba00e6399703be/multidict-6.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3126f2a96704505aa4e92a72d6e8a5d7f29d40a987ced8bf69e29d71dfc71fbc", size = 250812, upload-time = "2026-09-09T13:54:49.509Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5f/6b0b64aa0cd346b07831dabaa6ccda0e73014c5df044b68baa763f0f0552/multidict-6.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:23c9ee89967b6a9b4048acb3b93b660ed714ce9c8bf3bbe652959bc120dc02dc", size = 281606, upload-time = "2026-09-09T13:54:51.288Z" }, + { url = "https://files.pythonhosted.org/packages/31/8c/b846b6796f26d496efb07fedef2b69f6de533da32a56f12d236722a96157/multidict-6.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a62e302fc8cd6aa8972207e7e951d1fdee7c1dda18568305041d19f0e2c00f5", size = 281733, upload-time = "2026-09-09T13:54:53.05Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f3/bf14a39d4af5697fd9404baaf70a0aeeb82d258b95de5cb16b1a7f98ae6f/multidict-6.8.0-cp313-cp313-win32.whl", hash = "sha256:093167d22a8c95af30f597b8a5686f20a14512989942d4be804d119899caca20", size = 47738, upload-time = "2026-09-09T13:54:54.676Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/598511a5741a3cb374971b3b02eda8a09896118ba528a54795f7e7e8bfb4/multidict-6.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:f25b61a708bd276e8cbb6afcbbf1b8e793a3be70ba0a842d0b8692020f83b706", size = 51609, upload-time = "2026-09-09T13:54:56.38Z" }, + { url = "https://files.pythonhosted.org/packages/fd/b7/6f5c1bd4ffe42d4a6db0f2f65491d4088e9c25c990358fb31a614621d664/multidict-6.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb36381e1f9f9d06eba2f10bdd438e5d20c07d5b55e1a3eee30b9f44cbf52316", size = 48280, upload-time = "2026-09-09T13:54:58.03Z" }, + { url = "https://files.pythonhosted.org/packages/ab/85/153341590e233a967c1d6791a83402d01693dec0f4c1f695606ef16c7ed2/multidict-6.8.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:f8b09b25e0f4dc2ea9e2adbb1cc3ba11a94d6fa3dd978ae659c8743052e1afbc", size = 53758, upload-time = "2026-09-09T13:54:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ff/44f72d516ece0398683ef52061797d83a74b16b8c1e4587408e97959d783/multidict-6.8.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1f57c414be82490bc0e0305fdb834186229b2d9b6a35fa0afd1eb1a772d125ab", size = 47495, upload-time = "2026-09-09T13:55:01.382Z" }, + { url = "https://files.pythonhosted.org/packages/50/5f/6e118f761b024dd35d26c2fe7ba41572bb0e8ac5f8cfccbbcbc2ff76da4e/multidict-6.8.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:00be37bde741bf60871082cd347a093218c44886e99231b7516671c70f2c280d", size = 48540, upload-time = "2026-09-09T13:55:02.989Z" }, + { url = "https://files.pythonhosted.org/packages/e8/4b/3eed744491b32f0e318e7db89dc06858732362f706e8d045fa9ab51a343a/multidict-6.8.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e37b744849fb631bb52e3dadde35ffeee365a6c41cf71257b5b7acc9cd83fd38", size = 83130, upload-time = "2026-09-09T13:55:04.554Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/95c2c0ddcccb9a41ffbaa5df8ea059a8ff81916b7617a8847ecd89ed8061/multidict-6.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c2b2a96cf1dd99fe7867be4c013314225f4d5786e6685906e29932d42aca6f11", size = 50574, upload-time = "2026-09-09T13:55:06.387Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b7/f4f4989594f99bc121ad9277090c4e49819b08ab1a96e132b628a9e10b7d/multidict-6.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bea7df027015856ba5d0a88e3b4777ff8cb5c66b58fc108050fe79d4dd9d4d2d", size = 48786, upload-time = "2026-09-09T13:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/b2/86/f1d86a0222f31fb3df8eef3d6c9abf7e8d65d49edd8d0d7e7afaf23d23cc/multidict-6.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d3da668e903c934ed0b587ecacfed6901f6ae6384a6e975887592b61845e78bc", size = 276670, upload-time = "2026-09-09T13:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/03/50/6945c50f86a978b2bcace9ca344165ff80883be47d984489bbba8fa0ab20/multidict-6.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64eaeda36ee8d88f9e8616a587a8c66a663283cf6e0dcf013c1ddd8c758e4aef", size = 279339, upload-time = "2026-09-09T13:55:11.685Z" }, + { url = "https://files.pythonhosted.org/packages/ee/2c/e649889ba23fd1f4442a85427b99d9e6261226b2ac31914aa7f5b241d947/multidict-6.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac746cb365bac1c462da9e3e6ab8904a8efe2217a56b0b2e3d9480f41d2b2602", size = 252549, upload-time = "2026-09-09T13:55:13.527Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f8/1023b66e011b1395fb160dabb0f0608ef67e569f0bdb2c1d5ac9b2f2adc6/multidict-6.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18f0e06360c3e451a3ab800355773c8d125a758238d780c800b0ee5e90ee903c", size = 286203, upload-time = "2026-09-09T13:55:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/7e/6c/48aea545cbda6d0444848ec23d988c13b86538a00a1b7d3868cc2382ff94/multidict-6.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69708fecaa88bcb2341397b49fc95057a835b02a3670c551b37f95dd79e64e3a", size = 285039, upload-time = "2026-09-09T13:55:16.928Z" }, + { url = "https://files.pythonhosted.org/packages/68/2a/066123b17291671bf67d2a5c65ee81a48de53913bd1b1578791519eacdb0/multidict-6.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9606f583e7acaf61e7b3f56074e14037b9af7cb194590edfc0114b3ae5931ff7", size = 281075, upload-time = "2026-09-09T13:55:19.155Z" }, + { url = "https://files.pythonhosted.org/packages/47/20/4f0b2c485da2e8a659cc677717a3745872918c9c85064491a1ef75d7a3bf/multidict-6.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1f66fe6a021173d0d47968491791966b9f3e6d61115f2491744aa0c07a6e67af", size = 250431, upload-time = "2026-09-09T13:55:21.07Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c7/b9a288901577aa0b82c33c64d52246c88076d260ad7b6c16b021ca0f8e99/multidict-6.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:be007d1aee2cbd530347dcafedb400891a3b5f1bd7135f95cf5d5b330b5219ee", size = 273891, upload-time = "2026-09-09T13:55:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/da/51/0ba50cab2cfd067988de2abb73f23076ac727fe18d03f1368a59def64727/multidict-6.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8457aff3c12a89a8e1c4674de5c777857fbc429f40fe117a3d29538547cbc364", size = 265262, upload-time = "2026-09-09T13:55:24.77Z" }, + { url = "https://files.pythonhosted.org/packages/0f/d6/e5be1117dbca6eb9ce231142b7e20599418bb3500147db51bf844ce8afcb/multidict-6.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:95c27b4f3f04320fc44e338573f40c5c956b504a7fcf081a157fd0b02579311c", size = 278033, upload-time = "2026-09-09T13:55:26.67Z" }, + { url = "https://files.pythonhosted.org/packages/d2/28/cad0afaec3caa56ea2c1ceed43c164d62ad3e83e950daf0d0c87bcf9dca7/multidict-6.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d244cf6b52b5ba1c34c3832f4652a668ebb36d95949b96eed9a1c54d916a90dd", size = 281717, upload-time = "2026-09-09T13:55:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d2/025702df0b69b856db70a4d66f77622f51c3d99771ec9a07f3ca80f7e098/multidict-6.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5bbbb696c8024475b1877d14ce20d5f1cc05b8f6d786cea0fe3aa7fedc02e891", size = 247124, upload-time = "2026-09-09T13:55:30.497Z" }, + { url = "https://files.pythonhosted.org/packages/b4/96/9dddca563f06a921956389c0bc9b894355b98b0bdf62299e2560c50afb6d/multidict-6.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:cbd86f9787c5e2f5fd27d8b21458222f107347c6731c4e93dde68f554b466a2d", size = 275954, upload-time = "2026-09-09T13:55:32.57Z" }, + { url = "https://files.pythonhosted.org/packages/ec/91/8b2f1f2a774a955665f268340a2b59db7020c5f12baac02ae9ef1b1660cf/multidict-6.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2f8a4b0b4d639d525928c7f30de527bfdf9ead6e44a5e8cb9c50aced5e4590cb", size = 275508, upload-time = "2026-09-09T13:55:34.368Z" }, + { url = "https://files.pythonhosted.org/packages/b6/1a/e2cabdfc0880a61a99d2b8bc361035036fb5a2c6af31ea3fa054ba1065c5/multidict-6.8.0-cp314-cp314-win32.whl", hash = "sha256:8890c89d662560e51c55ac1304d6f919b23942abe9ae1127cb1de9aa6132fa52", size = 46938, upload-time = "2026-09-09T13:55:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/b9/7c/11234bcba62c22a58f2ba168499cfe3531f49de3edd5090d04a8c6cdc936/multidict-6.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:45cc39ba50fb0754a4359b90f8229ae08598fe2266abe3521b4e5a9ba916534a", size = 50291, upload-time = "2026-09-09T13:55:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/ab/61/793668439df924752a8137d6db0de97ed1add494779b01e4764dfc60571b/multidict-6.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d0264f8d5cb0a803f650a6a8572dfa0cd1e099a2234c588dc8fb220b415b865f", size = 47622, upload-time = "2026-09-09T13:55:39.335Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b8/3c091b929e6b5b2f6e0eba2232178e76d4503c8b96b92dfc281ff1d823be/multidict-6.8.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1969971900b0871530f9b62280dcc2d75688e74d2a69262bc01faf2b96c78f04", size = 88789, upload-time = "2026-09-09T13:55:41.086Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d7/3df83fab22dd64615db71e3b3cc1346b581d1459719637ce52144f9f6558/multidict-6.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8180b635290a75af8478f1b3e9810135381ae24833293fe77b85c1c21ff842ab", size = 53399, upload-time = "2026-09-09T13:55:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/2d/78/41bd04c04b0aed16540c4856c9e012afc1c254298da154398308df05e26a/multidict-6.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4261863fc8b5ab1b815ede94e592e94c6af5b04616014929057e61859e7382a9", size = 51597, upload-time = "2026-09-09T13:55:44.569Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6b/7bc4cdddf624e1e7e0231734b1331729ea46df10d7c8fd3fce79756e7d0e/multidict-6.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0b143d53590e89f43153d81d505a8448d4d57354354385aef8a51d67ffefa27e", size = 264391, upload-time = "2026-09-09T13:55:46.548Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/d2a946e5938771e92c39354563e535ef6bc6dfe399dd4307c6df8dfea183/multidict-6.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da1c112c5784ccd9d32cd90be6739fee32644e874eff6ae8f0497cba3e352e58", size = 264680, upload-time = "2026-09-09T13:55:49.915Z" }, + { url = "https://files.pythonhosted.org/packages/33/6b/3f9e981c42e7eb9329918523f0f9362ceb0ac3ee0ee1165c28f674249d75/multidict-6.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f7eefd0233a7c33ca980a5cfef26f1e9b5e2137839e752a99963696729f12d91", size = 235420, upload-time = "2026-09-09T13:55:51.92Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e1/a3a33a039fb6d381800ae5d1d587b697b8c27fcdfe48819420f08703acba/multidict-6.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:348bb85e2038b40c007383616d73f734869063772372519549ebd7da1723d1a4", size = 270309, upload-time = "2026-09-09T13:55:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/95/5d/8b06724a957f2e480f159b9550988a67810fbe9555a09c5f6a2a4b829607/multidict-6.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:095f62ea4e7a3be2f6c567ab695ce10e950f2adb905c1bec82281593e0b2d2ad", size = 275169, upload-time = "2026-09-09T13:55:55.948Z" }, + { url = "https://files.pythonhosted.org/packages/ab/32/8f3dfe2ffa5d0df2a95f71e63c2f11fe3b5e1771f26ef73bb1af84de83f8/multidict-6.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be569fff1d85cd29391c431c5641c8772acb75bbdc61e60a8e82fceb9023d385", size = 264900, upload-time = "2026-09-09T13:55:57.803Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/d5829fc00a055d6ab445e0876346ee9cdee670766cd4190dc0a496188c0f/multidict-6.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3533a03e4e789baf6a286e7b0b1b6da3f3d7c3eab569686ee29ee1d8b52e2cb4", size = 242486, upload-time = "2026-09-09T13:56:00.002Z" }, + { url = "https://files.pythonhosted.org/packages/b7/58/e8d7874038e31e0533182d1c3c5331a856b9c849a71bb26a21850e8c91e1/multidict-6.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1bdb9b8fba5a9aef673ec90db3f55b1ce743f2fbdea4d37dc04d14ccdfc153ff", size = 259916, upload-time = "2026-09-09T13:56:01.802Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f8/1b56a7401acda20efc016440f4fad3bef66c4aee54ca080ec143881ebb0d/multidict-6.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8d7b66c9e09c0bb0add2b5895e646b62a0849e71155066f215523de6b95cbe6", size = 251209, upload-time = "2026-09-09T13:56:03.767Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5b/68d67a9e302b0645a747ba910c30eb41f2834fcdc1d85f53eae2dfceee0a/multidict-6.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:563d6500ca80dac7bba6f48a78e0ffd87e21a7d4d24642c6503a2ddccd70c110", size = 264505, upload-time = "2026-09-09T13:56:05.795Z" }, + { url = "https://files.pythonhosted.org/packages/18/13/4dc304ba2c5f5307b474ab2ce1ed1f6b02b0b4e233c182e3981ed436c2e3/multidict-6.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:346ac52e56bcda320c0dcdfdd081947ed7cada33afea4e2284bef7b0733bff9b", size = 264916, upload-time = "2026-09-09T13:56:09.079Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0f/7b1f729d18369915009185201be5d0b8df0e525340fe6a600d2f8441d6cf/multidict-6.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4ee953a5ebaeed38dc21cc032ed17a9d9782802e00042200497ab4b01b0bf7c0", size = 236839, upload-time = "2026-09-09T13:56:11.273Z" }, + { url = "https://files.pythonhosted.org/packages/22/d1/eba1b88b18b7019d9136303fe77909257c40fabde5aaf138a4d900b6ce3c/multidict-6.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2f79cc3e8039a8cf5c77e0811b0807953fd52d0863b9b76970b20d696dc64a78", size = 265307, upload-time = "2026-09-09T13:56:13.379Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a6/6c1e4106faa27118ac612f4d664eaf909de252634785286262a627108e58/multidict-6.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43a4b56555bbcf8af161e7c7682bd93eec10f068c95844511864c018c8e5e13b", size = 259041, upload-time = "2026-09-09T13:56:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/c0/bc/ecfb8b6faa8e158a71b03bdf7f947f30e0bc5d899cc357573a76ab7bb1e5/multidict-6.8.0-cp314-cp314t-win32.whl", hash = "sha256:48ea524a25a1cd5972cf293bc95713918cba0bcd6fa9b992d906c857c546abe2", size = 50628, upload-time = "2026-09-09T13:56:17.837Z" }, + { url = "https://files.pythonhosted.org/packages/30/7f/e27fb699b70ad24dbd02ddee604658acb36f907c03c045baffe4ea774501/multidict-6.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d0be2b832435001bc623ca7f1499ca1a853d4f082fb61221a80ce71132f50b26", size = 55592, upload-time = "2026-09-09T13:56:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/eb/7a/76de70b2f6733696803f1ee56abe44a3757a52777383032c7373d3fea0f4/multidict-6.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:62b8e291a4f7edbf7cde7a43d831d893ba443a1b627498b53581943b0e348feb", size = 50300, upload-time = "2026-09-09T13:56:21.516Z" }, + { url = "https://files.pythonhosted.org/packages/ce/32/4de7320ae032dc768090d11f708d2d386df3db04cb6b8b0db0230cfc66c3/multidict-6.8.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:e192018b732f7b168e6604cbdf40fa8e05c996693b9eb445a0d8a73f4b77c5d3", size = 53761, upload-time = "2026-09-09T13:56:23.192Z" }, + { url = "https://files.pythonhosted.org/packages/5c/45/ecb641309dc2cdc6040f18e22c68eb5e94398f9404c4365d810f4292e053/multidict-6.8.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b25426f9f6ed402835617c8f23609a47045f91ecff365eb6734817e039a8ed25", size = 47505, upload-time = "2026-09-09T13:56:24.902Z" }, + { url = "https://files.pythonhosted.org/packages/eb/68/87d6161b9fef11943e0b894203da3fff561933ca3c9b2952b6e7100e9c9f/multidict-6.8.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:fa6c2880709c84457de104385b704fc28860f27e442ad13966fc4af8e714fe9c", size = 48549, upload-time = "2026-09-09T13:56:26.574Z" }, + { url = "https://files.pythonhosted.org/packages/97/f7/d852d2276407640cdbd29fe11cac6e93f70f59542cba174ef9d146738946/multidict-6.8.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:eabb03dc3e4ed6333ecd1cc9826ec80e7a98b5506deeb832d7260c8e44166d23", size = 83157, upload-time = "2026-09-09T13:56:28.227Z" }, + { url = "https://files.pythonhosted.org/packages/14/e3/16fe7ffa6090591d83cf6bc2486e77ce891705fb6d0191823140928311b5/multidict-6.8.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:59e539c4eb4d3a53b0e630a6ba2b2f2824732b5e73f90e30a280f12fde157b15", size = 50578, upload-time = "2026-09-09T13:56:30Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0c/e38e41c1087a599f86ff58a01f358abf7c4db3c26a3e90eebb3e02193ef1/multidict-6.8.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:835d5a90b11d1f5f8200ff3cc8316bded76eebebc92436398947a27657e645e7", size = 48815, upload-time = "2026-09-09T13:56:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f0/eb691f42af8e7775992f57904ec75dc356fc7cdc896e5f30879decdd26f2/multidict-6.8.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d2d236b8a44ae91536a12ebcb996bdb31cf27425f36b4d05c87f2ba2716050ba", size = 274804, upload-time = "2026-09-09T13:56:36.741Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/28472ccfeb43c00a043c0385ca4294da21a5957859fb7860e2ebdb3e3011/multidict-6.8.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb9a60b7faa5d37c426fa91cf4d6738182a1f2755b9fab7c9c64cd466c4ce51e", size = 279693, upload-time = "2026-09-09T13:56:38.531Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a1/2b4fe73e5fecff807b47650a155c391a103136428cb21d6ba8e39c5912b5/multidict-6.8.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ef606c15cac6c90279acf34120784b6f36662cbf382defd3955cd8f1115336b", size = 254969, upload-time = "2026-09-09T13:56:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/44/e0/c97d1822783dfe52e02fd150fa3f02eb22410211a9e2615f71541803ed4b/multidict-6.8.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:68186a2d4051c8ffd17be33553bea2ec9bbc8ef860fe2980a221d96126296f31", size = 286392, upload-time = "2026-09-09T13:56:42.234Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d9/772f1339e1d051236bcc137b0eac2b4aaaa0bbb56aaf924e9aaba901d9c1/multidict-6.8.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2cc66abb85e2108c9ff8a1c0d20fa260bf690bbb33caef4ff3ecb2c2cbdfff5d", size = 285348, upload-time = "2026-09-09T13:56:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/cd045747e4680362e02955a82c468e95b5e4d319e3a79574b3fb677de568/multidict-6.8.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69b3e519a132bb943b0daae15fc8c2168706b17f826481d32a32a5e784b129e3", size = 282721, upload-time = "2026-09-09T13:56:46.088Z" }, + { url = "https://files.pythonhosted.org/packages/35/14/0802d9a3aae4ef21eaa39adbd729a380fa095932105e1424e417b53e783f/multidict-6.8.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e41226ecf607f062fe34a2f4cf64ad3a89e3a0180dc800b463b6b14c06dd10dc", size = 253168, upload-time = "2026-09-09T13:56:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/b1/64/3f92298bab8fbe1332e708863fb55b66e755be6f416b3459720d48b33af9/multidict-6.8.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:610c7637bc36b90f39e6c66f710f93d57018f83d53e1e187caaa218c6892b95f", size = 274209, upload-time = "2026-09-09T13:56:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/08/c2/2001ac0eac1a8b7390a5902d7115f66d4f256268057a502200b6ab12dad7/multidict-6.8.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:65c85c79f5a2c04fbbc18f006c014674dc5fdf270cb978d8862c82c6f694e60c", size = 268044, upload-time = "2026-09-09T13:56:52.033Z" }, + { url = "https://files.pythonhosted.org/packages/0d/90/78a9e26c85f89abd562a67f7fcbaef9007fd5c37bb9efac19f1cf604e7c2/multidict-6.8.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:628ff11e6720f90acd0c305dfa3339f04a783a20de8cda6ac333ba46447261e8", size = 274806, upload-time = "2026-09-09T13:56:53.975Z" }, + { url = "https://files.pythonhosted.org/packages/3d/71/713bd445421b21531234c1f3630b768192cb9d80c8b1c5b05c5b505ff4c0/multidict-6.8.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0935971bffd0b479fc90c4811ca787703e93fcb6afea939a375dfc80285ab368", size = 281890, upload-time = "2026-09-09T13:56:55.848Z" }, + { url = "https://files.pythonhosted.org/packages/de/a5/1387c538663e2dc8c27bbc7cd6955cb66de0f55c780cf7cd0fc06a1a16ca/multidict-6.8.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9442b14eec262a1f74369bbd07e75bc5155105164649a4b9fbc1ebc7b8fb0b14", size = 249749, upload-time = "2026-09-09T13:56:58.01Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/104296c9d70896b9759ce0812aa4899fab76d8b16bb32dcc5a78ab547c89/multidict-6.8.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:397599503b718f0137f26d3f6532d6955069cd2e5917c47ef581495bc2529ff8", size = 276138, upload-time = "2026-09-09T13:57:03.591Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0a/f2a0c2658e9d7ff5964ec2820a02054558636fafd663230ddc8310b8ed39/multidict-6.8.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9e37024b41d7a7e7e9cce14b248d54707c21c2a2ea30a47b71bdcefcafec00f2", size = 277077, upload-time = "2026-09-09T13:57:06.024Z" }, + { url = "https://files.pythonhosted.org/packages/98/50/bc46566caffba5c1c4a510519156371edf7c4ecd35c9ef917d0c1803487d/multidict-6.8.0-cp315-cp315-win32.whl", hash = "sha256:071da134651b04a8507dfb331ac0988f376337c2aea59486bf20989fb5b5a64e", size = 46930, upload-time = "2026-09-09T13:57:08.009Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1a/cafb31049ecc1a6ce52bcc69fa436cca239adc057b1718a0c49044848663/multidict-6.8.0-cp315-cp315-win_amd64.whl", hash = "sha256:3bafff8598f0528017ddc74194e5451d5c22d046c98935f8f86247b0f286e4f8", size = 50294, upload-time = "2026-09-09T13:57:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/6b/51/00e037da14cd1d894b123e0bbe62de5c561679a6ab23ab1c009f2965dcda/multidict-6.8.0-cp315-cp315-win_arm64.whl", hash = "sha256:e886ef8c9879105fe4fc99417447b3a5f35d1131412ce839470bd2089fe2043f", size = 47626, upload-time = "2026-09-09T13:57:11.738Z" }, + { url = "https://files.pythonhosted.org/packages/52/f7/aeb947982197e8b4f5c4da3961ee473ea5a050b94a6ff3b88baf64621401/multidict-6.8.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:883284137e25318ed9735b742ae46341a864888fae28e8b6314c4f84da080f08", size = 88801, upload-time = "2026-09-09T13:57:13.957Z" }, + { url = "https://files.pythonhosted.org/packages/35/d8/593948c016c3f850e3cd56a4e0144151eb409d2b8690f0c0ce7f7d33dbea/multidict-6.8.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:ca52b9ec80851366197577154c862c4c4c7036ca76ae94cef5cb59c5cfeab944", size = 53376, upload-time = "2026-09-09T13:57:15.94Z" }, + { url = "https://files.pythonhosted.org/packages/58/b9/097a05bca533027c0477b6a90bf927dbbb4b23cc9090bbb37a2e972af8d5/multidict-6.8.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:91fa75d0a693832106d98f66c849f034f21c828d14437f1fb97d3784aab89e84", size = 51629, upload-time = "2026-09-09T13:57:17.685Z" }, + { url = "https://files.pythonhosted.org/packages/fe/07/938ed21967f12380d0b8861645fb65a942f3669e31d5163ed94d23103b61/multidict-6.8.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:307c1acd812fe897e7fbe10c6758822e8c04be4e7c60a9f54901cdf8b5ab8bc3", size = 261967, upload-time = "2026-09-09T13:57:19.752Z" }, + { url = "https://files.pythonhosted.org/packages/89/e8/e66bf843fd29c01712dde9edeb9f4ad0ffab06ab4ada4b721ad7bc73b3d5/multidict-6.8.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf684986a2490628f059a99dd107b566a2d34cf947f8eb8387e0500a1f90c5", size = 265923, upload-time = "2026-09-09T13:57:21.784Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f8/e9be849b225af28a8eee2c6bfea23594a777c753fe97e2ff7e2180c8935a/multidict-6.8.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:77745725125d01fd613b6db043362aa7c6bfbfdb23d45dbfc3d92bf58160af62", size = 239380, upload-time = "2026-09-09T13:57:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/67/4dbad08f5081978c591afae9e836ec9ddae90e9e76be6d6ce10757483dc4/multidict-6.8.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8daafaa0b2eb43f76898ced78b1e0fb91b38c4fa50da516c18067f2a2d578c20", size = 271591, upload-time = "2026-09-09T13:57:26.611Z" }, + { url = "https://files.pythonhosted.org/packages/92/3f/e9c97222d7e104e54e556f118ec7d091ab41a0c10c630f2b97e5b43f5404/multidict-6.8.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c68e0c0649d17c2d0339e3674e86a4aeba4a7e6b21c1e394cf947a95433b31d0", size = 276091, upload-time = "2026-09-09T13:57:28.997Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/728e7ce05ac9c0303554e7162e74d91fe49e65bad7dfbb377f783dd32c0a/multidict-6.8.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8a5ac357ac283490a8d1899b0383355fd1f8634b14ba0d59e4c0dd97db85556", size = 266493, upload-time = "2026-09-09T13:57:31.256Z" }, + { url = "https://files.pythonhosted.org/packages/8f/74/7c658d2769863af16fb7d7c6be50b29659892a06a632858863eee3a31842/multidict-6.8.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46029e6e27a3ec0dc55b53f58df82d10f04c5e111f78248279b530bedad2c30a", size = 245302, upload-time = "2026-09-09T13:57:33.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/2c/d4350a20a0e8c66a447d694e8713438262665203fe826c3f4e385f052b72/multidict-6.8.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8d1046b5427dcafe6e8a0e07527dd74f1ee694006160162f53f3a17f15aad3b4", size = 261016, upload-time = "2026-09-09T13:57:35.651Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/83df999c8beb72a012cfac42f2b833c4a48f8e836fd4407747b355a2430e/multidict-6.8.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:f1f4a220db6ed7c8fd16b6d644ffd1f082651693204daf3275e049fadc849e39", size = 255021, upload-time = "2026-09-09T13:57:37.758Z" }, + { url = "https://files.pythonhosted.org/packages/fb/13/f2c0a2dac6d91f74aa124f3e9f07ec497ceae5ed2df2753d249601cd7262/multidict-6.8.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:029897732a9c798737457e382bf84e8c64237eff224a90aea2639f4413c45e4e", size = 263066, upload-time = "2026-09-09T13:57:39.897Z" }, + { url = "https://files.pythonhosted.org/packages/59/1d/730008d4639ace731bbb1399e1ac13cbdf506f7d6fb861d75044ffb3994d/multidict-6.8.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:29be9fd289e9ab8f480996ea2f686e1654b80242033843cb11691688329423f1", size = 266510, upload-time = "2026-09-09T13:57:42.39Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/bec67a5d206dc5748e50c93f6f71deec14305c3657cfe250c3887caf7839/multidict-6.8.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:29631224698de1e42abc8fa7658d830e0aed0029785144b5832b695da5adef2f", size = 239423, upload-time = "2026-09-09T13:57:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/9e/db/5f153fe51fbac7d80f3bb8bd6fab8db8b6cd061e7a11371676dfed3712bc/multidict-6.8.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:962f18c59a000f30b084ea2e6b8001521bb315efd4e5f10acf9fb36f366b7882", size = 266902, upload-time = "2026-09-09T13:57:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/89/0f/9efca48a351551de4dc0c183f523109dbe87c645a4732d5f1c70b4880dca/multidict-6.8.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c60e50bc5b07faac92fd3a20fa21cc8cf3e3f7204d2867b206c73293ebc19101", size = 260887, upload-time = "2026-09-09T13:57:48.268Z" }, + { url = "https://files.pythonhosted.org/packages/df/d8/bb879a62e0809448e53f6237e71670066ecf3bbc5896a7a6705b6628d86a/multidict-6.8.0-cp315-cp315t-win32.whl", hash = "sha256:fc5460940f50dff00731b4132366840ba9685286ea88ea104b661899084f3fea", size = 50533, upload-time = "2026-09-09T13:57:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/fe/62/3e5308d8871636e4b9620e4b3acfcf2b5caf79b19d317690ec13f7fc8b57/multidict-6.8.0-cp315-cp315t-win_amd64.whl", hash = "sha256:b367c342327717d644db4c0ddb37ceb655c84822215ea0773a3a36911b74b71d", size = 55572, upload-time = "2026-09-09T13:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cc/d3c10e10ee3bb7a7b4abbb3157306b2ce7e0018c9c2d16b32b468739d2b7/multidict-6.8.0-cp315-cp315t-win_arm64.whl", hash = "sha256:0c1c4debad7337627b86837abdf0237ca3cb3d7e17de7eab0177c263878546d4", size = 50322, upload-time = "2026-09-09T13:57:54.099Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ee/be4e1a4b7a2b27f4fb6936510d4bebcb41b0562c946930ad26916e069cf9/multidict-6.8.0-py3-none-any.whl", hash = "sha256:75daa15ca16d6285eb2e104b2f05ee6f8d9836c68da3ce5c85f615a0450eed0e", size = 16297, upload-time = "2026-09-09T13:57:56.106Z" }, +] + [[package]] name = "packageurl-python" version = "0.17.6" @@ -474,6 +1046,117 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + [[package]] name = "protobuf" version = "7.36.0" @@ -501,6 +1184,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -627,6 +1319,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -1028,3 +1734,102 @@ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] + +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/db/3cb5df059756a45761cc3dee8fd25ec82b83a6585ea3542b969fda850f99/yarl-1.24.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3", size = 135043, upload-time = "2026-07-20T02:04:52.39Z" }, + { url = "https://files.pythonhosted.org/packages/44/f8/767d6bd5a03db63bc467df2fb56d6fafeae9667d74aea92cd6af399f828b/yarl-1.24.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a", size = 96942, upload-time = "2026-07-20T02:04:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/ce/97/10b939c44d7b28d1dbc389cfc7012306d1ea8dba01eaef44b39fffaee52a/yarl-1.24.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840", size = 97046, upload-time = "2026-07-20T02:04:56.638Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7a/b410dbe39b6255c55fb2a2bcee96eb844d0789235ddc381a889a90dc72d6/yarl-1.24.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966", size = 110512, upload-time = "2026-07-20T02:04:58.955Z" }, + { url = "https://files.pythonhosted.org/packages/83/c7/da591971f78a5617e1f21f5699858ebccd836fe181a6493788ffc91ba69b/yarl-1.24.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723", size = 102454, upload-time = "2026-07-20T02:05:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8e/73b0ed4de47289a78a96045d76d1cfe5e41848bf0da59ce25b2ec87ee05d/yarl-1.24.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb", size = 117617, upload-time = "2026-07-20T02:05:02.325Z" }, + { url = "https://files.pythonhosted.org/packages/cf/14/b744747bc4f57a8d55bd744df463457524583e1e9f7538b5ace0346ab92e/yarl-1.24.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780", size = 116135, upload-time = "2026-07-20T02:05:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/66/ca/95aa4d0e5b7ea4f20e4d577c42d001ed9df207569fdb063cc5ed4ebb496b/yarl-1.24.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e", size = 111935, upload-time = "2026-07-20T02:05:05.738Z" }, + { url = "https://files.pythonhosted.org/packages/72/0d/d2ad8d6b147832d177a4e720ba1962fe686eb0913b74503b3eca094b8bba/yarl-1.24.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2", size = 110010, upload-time = "2026-07-20T02:05:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/50/18/eb335e4120903903f4865041355ae46256a2406eb2865bc24827f4f27b61/yarl-1.24.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58", size = 110058, upload-time = "2026-07-20T02:05:09.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/70/97353add32c62ad6f206d948ac5a5ee84398225e534dc6ed6433d1b335b6/yarl-1.24.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61", size = 103308, upload-time = "2026-07-20T02:05:11.31Z" }, + { url = "https://files.pythonhosted.org/packages/68/39/5e7398d4b6f6b3c9062823ebc60802df5b272e3fe9e788f9734c6ee46c85/yarl-1.24.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6", size = 116898, upload-time = "2026-07-20T02:05:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/09e52f2239e8b96357eccca05915382e4ba5405ebfb623b6036040d99654/yarl-1.24.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f", size = 109400, upload-time = "2026-07-20T02:05:14.821Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6a/e94133d4c2d1a14d2384310bf3e79d9cf32c9d1eae1c6f034fb80d098fa1/yarl-1.24.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077", size = 115934, upload-time = "2026-07-20T02:05:17.78Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/34955ed967b976fc38edcbb6d538dee79dbda4cb7fc7f72a0907a7c78e0f/yarl-1.24.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd", size = 112178, upload-time = "2026-07-20T02:05:19.675Z" }, + { url = "https://files.pythonhosted.org/packages/f5/46/d7bd3a8859d47dcfaffd7127af7076032a7da278a9a02e17b5f37bfb6712/yarl-1.24.5-cp311-cp311-win_amd64.whl", hash = "sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25", size = 97544, upload-time = "2026-07-20T02:05:21.523Z" }, + { url = "https://files.pythonhosted.org/packages/01/69/c1bfd21e32c638974ea2c542a0b8c53ef1fa9eff336020f5d014f9503ff2/yarl-1.24.5-cp311-cp311-win_arm64.whl", hash = "sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a", size = 93359, upload-time = "2026-07-20T02:05:23.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, + { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, + { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +] diff --git a/zensical.toml b/zensical.toml index 7e16c712..bb422fb8 100644 --- a/zensical.toml +++ b/zensical.toml @@ -44,6 +44,7 @@ nav = [ {"Architecture" = [ "documentation/egress-gate/architecture/index.md", {"Request lifecycle" = "documentation/egress-gate/architecture/request-lifecycle.md"}, + {"Admission without harness forks" = "documentation/egress-gate/architecture/admission.md"}, {"Service boundary" = "documentation/egress-gate/architecture/service-boundary.md"} ]}, {"Reference" = [