From 3230d487614e6a7df6e0f4818c374c105703c3f1 Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Sat, 20 Jun 2026 13:42:04 +0300 Subject: [PATCH 01/13] fix: migrate to pi-sidecar and improve AI cherry-pick conflict resolution - Replace ai-cli-runner with pi-sidecar-client - Add sidecar-helper/ for Docker multi-stage build - Create entrypoint.sh with lifecycle coupling (trap + monitor + readiness) - Enhance cherry-pick prompt with commit context and resolution rules - Add post-resolution scope verification - Update tests and documentation Closes #1126 --- .gitignore | 2 + CLAUDE.md | 28 +- Dockerfile | 21 +- entrypoint.sh | 32 + pyproject.toml | 2 +- sidecar-helper/package-lock.json | 3107 +++++++++++++++++ sidecar-helper/package.json | 17 + sidecar-helper/src/server.ts | 3 + sidecar-helper/tsconfig.json | 15 + uv.lock | 24 +- webhook_server/config/schema.yaml | 2 +- webhook_server/libs/ai_cli.py | 26 +- .../libs/handlers/runner_handler.py | 190 +- webhook_server/tests/test_runner_handler.py | 214 +- 14 files changed, 3568 insertions(+), 115 deletions(-) create mode 100644 entrypoint.sh create mode 100644 sidecar-helper/package-lock.json create mode 100644 sidecar-helper/package.json create mode 100644 sidecar-helper/src/server.ts create mode 100644 sidecar-helper/tsconfig.json diff --git a/.gitignore b/.gitignore index fefaeebb6..73f8ab611 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,5 @@ find_unused_code.py .claude-flow/ .swarm/ CRUSH.md +sidecar-helper/node_modules/ +sidecar-helper/dist/ diff --git a/CLAUDE.md b/CLAUDE.md index 974d67654..9d6d8d6c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -684,6 +684,30 @@ AI-powered enhancements controlled by `ai-features` config (global or per-repo). **Sub-features:** `conventional-title`, `resolve-cherry-pick-conflicts-with-ai` -**On AI CLI failure:** Error is logged, flow continues without suggestion +**On AI call failure:** Error is logged, flow continues without suggestion -**Module:** `webhook_server/libs/ai_cli.py` - shared AI CLI wrapper +**Module:** `webhook_server/libs/ai_cli.py` - shared AI wrapper (pi-sidecar) + +**Post-resolution verification:** After AI resolves cherry-pick conflicts, `_verify_cherry_pick_scope()` compares `git diff --stat` of the original commit vs the cherry-picked commit. Logs a warning if the cherry-picked commit has fewer file changes (possible dropped changes). Informational only — never fails the cherry-pick. + +**Required environment variables for pi-sidecar:** +- `ACPX_AGENTS=cursor` — enables cursor model discovery +- `VERTEX_CLAUDE_1M=true` — enables Claude 1M context window models via Vertex AI +- `GOOGLE_APPLICATION_CREDENTIALS` — already set for Vertex AI access + +### Sidecar Architecture + +**`sidecar-helper/`** — Node.js pi-sidecar bridge that provides AI provider integration (cherry-pick conflict resolution, conventional title suggestions). Contains a minimal TypeScript wrapper that imports and starts the `@myk-org/pi-sidecar` server. + +**Container startup (`entrypoint.sh`):** +1. Starts the sidecar as a background process (`node sidecar-helper/dist/server.js &`) +2. Registers cleanup trap — kills sidecar when main process exits +3. Spawns monitor subshell — if sidecar dies unexpectedly, kills PID 1 to crash the container +4. Waits for sidecar readiness — polls `/health` endpoint (up to 15s) +5. Runs `exec uv run entrypoint.py` as the main process + +**`SIDECAR_PORT`** env var controls the sidecar listen port (default: `9100`). + +**Dual healthcheck:** Dockerfile `HEALTHCHECK` verifies both the webhook server (`:5000/webhook_server/healthcheck`) and the sidecar (`:${SIDECAR_PORT}/health`). Container is unhealthy if either fails. + +**Docker build:** Multi-stage — `sidecar-builder` stage (node:22-slim) runs `npm ci`, `npx tsc`, `npm prune --omit=dev`. Final stage copies only `dist/`, `node_modules/` (production), and `package.json`. diff --git a/Dockerfile b/Dockerfile index 26c5a9c2a..2324ab5c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,16 @@ +# Sidecar build stage +FROM node:22-slim AS sidecar-builder +WORKDIR /sidecar +COPY sidecar-helper/package.json sidecar-helper/package-lock.json* ./ +RUN npm ci +COPY sidecar-helper/ . +RUN npx tsc +RUN npm prune --omit=dev + FROM quay.io/podman/stable:v5 EXPOSE 5000 +EXPOSE 9100 ENV USERNAME="podman" ENV HOME_DIR="/home/$USERNAME" @@ -80,11 +90,18 @@ COPY --chown=$USERNAME:$USERNAME pyproject.toml uv.lock README.md $APP_DIR/ WORKDIR $APP_DIR RUN uv sync +# Copy sidecar from build stage +COPY --chown=$USERNAME:$USERNAME --from=sidecar-builder /sidecar/dist $APP_DIR/sidecar-helper/dist +COPY --chown=$USERNAME:$USERNAME --from=sidecar-builder /sidecar/node_modules $APP_DIR/sidecar-helper/node_modules +COPY --chown=$USERNAME:$USERNAME --from=sidecar-builder /sidecar/package.json $APP_DIR/sidecar-helper/package.json + # Copy application code after dependency install COPY --chown=$USERNAME:$USERNAME entrypoint.py $APP_DIR/ +COPY --chown=$USERNAME:$USERNAME entrypoint.sh $APP_DIR/ +RUN chmod +x $APP_DIR/entrypoint.sh COPY --chown=$USERNAME:$USERNAME webhook_server $APP_DIR/webhook_server/ COPY --chown=$USERNAME:$USERNAME scripts $APP_DIR/scripts/ -HEALTHCHECK CMD curl --fail http://127.0.0.1:5000/webhook_server/healthcheck || exit 1 +HEALTHCHECK CMD curl --fail http://127.0.0.1:5000/webhook_server/healthcheck && curl --fail http://127.0.0.1:${SIDECAR_PORT:-9100}/health || exit 1 -ENTRYPOINT ["tini", "--", "uv", "run", "entrypoint.py"] +ENTRYPOINT ["tini", "--", "/bin/bash", "entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 000000000..6e7e3f458 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +# Start Pi SDK sidecar in background with lifecycle coupling +if [ -f "$APP_DIR/sidecar-helper/dist/server.js" ]; then + export SIDECAR_PORT="${SIDECAR_PORT:-9100}" + export SIDECAR_ACPX_EXTENSION_PATH="$APP_DIR/sidecar-helper/node_modules/@myk-org/pi-sidecar/node_modules/pi-orchestrator-config/extensions/acpx-provider/index.ts" + node "$APP_DIR/sidecar-helper/dist/server.js" & + SIDECAR_PID=$! + echo "[sidecar] Started Pi SDK sidecar (PID $SIDECAR_PID) on port $SIDECAR_PORT" + + # Kill sidecar when main process exits + trap 'kill $SIDECAR_PID 2>/dev/null; wait $SIDECAR_PID 2>/dev/null' EXIT + + # Monitor sidecar — if it dies, kill the main process too + # TERM trap prevents misleading "died" message on normal shutdown + (trap 'exit 0' TERM; while kill -0 $SIDECAR_PID 2>/dev/null; do sleep 5; done; echo "[sidecar] Sidecar died, shutting down container"; kill 1 2>/dev/null) & + + # Wait for sidecar to be ready (up to 15s) + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:$SIDECAR_PORT/health > /dev/null 2>&1; then + echo "[sidecar] Health check passed" + break + fi + sleep 0.5 + done +else + echo "[sidecar] WARNING: sidecar-helper/dist/server.js not found, AI features will not be available" +fi + +# Execute the main application +exec uv run entrypoint.py diff --git a/pyproject.toml b/pyproject.toml index 52da77f29..574ff09ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,7 +76,7 @@ dependencies = [ "pydantic>=2.8.0", "psutil>=7.0.0", "fastapi-mcp>=0.4.0", - "ai-cli-runner>=0.1.0", + "pi-sidecar-client>=1.1.0", ] [[project.authors]] diff --git a/sidecar-helper/package-lock.json b/sidecar-helper/package-lock.json new file mode 100644 index 000000000..67925eba2 --- /dev/null +++ b/sidecar-helper/package-lock.json @@ -0,0 +1,3107 @@ +{ + "name": "webhook-server-sidecar", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "webhook-server-sidecar", + "version": "1.0.0", + "dependencies": { + "@myk-org/pi-sidecar": "^1.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@agentclientprotocol/sdk": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.21.1.tgz", + "integrity": "sha512-ZTLH+o9QxcZDLX/9ww+W7C2iExnXFM+vD/uGFVSlR61Kzj9FaxUqBC6Rv/kwgA7qVWYUEI9c5ZNqCuO9PM4rKg==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@anthropic-ai/vertex-sdk": { + "version": "0.11.5", + "resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.11.5.tgz", + "integrity": "sha512-V7sB5nY80unEQu8lSQaEzh1WhYwpIdpC3iXNRHUskghkuQDhS6dQu2ASZBgA5MNuJ1Yv5PhY61NM15dLaQhPQw==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": ">=0.50.3 <1", + "google-auth-library": "^9.4.2" + } + }, + "node_modules/@anthropic-ai/vertex-sdk/node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@anthropic-ai/vertex-sdk/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@anthropic-ai/vertex-sdk/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@anthropic-ai/vertex-sdk/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@anthropic-ai/vertex-sdk/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "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/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.1073.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1073.0.tgz", + "integrity": "sha512-Vecj8r9/KIh/Nu9T7CRoCw5EBqnmAa9Q+Iwi5J5Mr0IEBMH6KUoOgAjayfyEZjvvZTllLJ2dOAx5cYeIz8QD6A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/credential-provider-node": "^3.972.57", + "@aws-sdk/eventstream-handler-node": "^3.972.22", + "@aws-sdk/middleware-eventstream": "^3.972.18", + "@aws-sdk/middleware-websocket": "^3.972.30", + "@aws-sdk/token-providers": "3.1073.0", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.22.tgz", + "integrity": "sha512-YofH63shc6YRdXjz80BJkpJW+Bkn0Cuu2dn4Rv7s9G2Idt58tgtzQEWxrR2xVljlVfIBeUjPuULnSVYLke3sUQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@aws-sdk/xml-builder": "^3.972.30", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.48.tgz", + "integrity": "sha512-h6FEC95fbexUd6zxm4PdgS82bTcI2PRtUb2ZwMipb/Xr8bPwtf0G8rBo2jp7NA24Mbx2JA8/WingiYpA9RCCyw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.50", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.50.tgz", + "integrity": "sha512-lJO3OLpjvz5m/RSBQmsG/CEUGsvCy5ruxKwPQaOCqxqCMuyYT2BZwQUTDZVVwqQ9LrZKuK24JSa6r31hL/tvkg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.55.tgz", + "integrity": "sha512-TBoF4buBGYhXjdZAryayY2TrkQj2B2KfE/msG4V53XCt+w0EhEwM2JRjx8p2grJ2C6gtH5++SAwEvGMRdi0yyw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/credential-provider-env": "^3.972.48", + "@aws-sdk/credential-provider-http": "^3.972.50", + "@aws-sdk/credential-provider-login": "^3.972.54", + "@aws-sdk/credential-provider-process": "^3.972.48", + "@aws-sdk/credential-provider-sso": "^3.972.54", + "@aws-sdk/credential-provider-web-identity": "^3.972.54", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.54.tgz", + "integrity": "sha512-hBWI3wZTdTGiuMfmPts6AWbAjFfRniOQnqx68tc2cQvRKWawFbN9wkLOVPWM1FAOyowZU73mC6Fi+rHSHNyLFw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.57.tgz", + "integrity": "sha512-u6dClpzNdWf1HGWz4wwhdXi1wiOofCLniM9S4BQQGlLAN9TW7VB+ld5V533GdKrYMaFeBGFqKnj0JCYvynLqwQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.48", + "@aws-sdk/credential-provider-http": "^3.972.50", + "@aws-sdk/credential-provider-ini": "^3.972.55", + "@aws-sdk/credential-provider-process": "^3.972.48", + "@aws-sdk/credential-provider-sso": "^3.972.54", + "@aws-sdk/credential-provider-web-identity": "^3.972.54", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.48.tgz", + "integrity": "sha512-w6VZwojPt12WnEkAUy6Nu4K6sWCbBmR7QX390b0nE6vRvkXbrYr9Lq9VySGkfjiMjpUA87op+J4EgvRmtWIDoQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.54.tgz", + "integrity": "sha512-23uZpIpF2SIFDCa1fcWa202tK4gGeyvX6GIIAjiB8WBsvsVRBMnJ/7dCxHzxf7eZT7GToJg837LDIBnZsl/VUg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/token-providers": "3.1071.0", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1071.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1071.0.tgz", + "integrity": "sha512-4LDW2Qob6LoLFuqYSYZq2AyTE9koSE9+i+n5UZcm10GpmQOK0zRD9L4uYlzItiTKksIWgC/qMFChAi3RvKYtMg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.54.tgz", + "integrity": "sha512-0Iv5QttS6wcATlodYKgvQj6B9Db51rx7NU9fqu0PoLeS4BIgdYMc/QK4smwLwpm5RFrs02V/eLyEFp3FklvlNQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.22.tgz", + "integrity": "sha512-tqPJv0dz4+O0hWGm1a6YekcMZyPhDFs/zH73Von7icaVT5n0Jqvm86typ3jRrG+qoUdPhALOnboRLTmnWQTlYQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.18.tgz", + "integrity": "sha512-OHpk8YoZi3yexPq8aFt1vN1IxA2zLKvsIR5GpWYylX/ve6kQmY7wxHNSFy/D3t2apMZ16rs76Co4dJWcDyIk3A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.30.tgz", + "integrity": "sha512-kH6N4f/Fzi9r/dYap8EQ+Zk4NOz8pl4AtWKhzAoG2C1/4YkIHok9APp/e+75woreWQq264n+LkrJsJVZ0Q+M1Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.22.tgz", + "integrity": "sha512-4IwtcYSxEIVw5hcp8ogq0CMbFNZFw7jJUetpfFUhFFeqsa1K8j2Ihg2hnxLyOp3stMZnXda6VzOmPi1AFZQXcg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz", + "integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1073.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1073.0.tgz", + "integrity": "sha512-Tolawuc3I9Q6pElcqoBQMLCiCOfKn3eqG4oNIRci4BurhsrJmzXkhF3N+6LRXJrWYFtJKfTkBuLbYCLr8+pwig==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz", + "integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.30.tgz", + "integrity": "sha512-StElZPEoBquWwNqw1AcfpzEyZqJvFxouG+mpDNYlcH6ZOrqd2CuIryv+8LV8gNHZUOyKyJF3Dq9vxaXEmDR9TQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "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/@clack/core": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.2.tgz", + "integrity": "sha512-0Ty/1Gfm+Kb07sXcuESjyKfwEhSy4Ns1AgeEisHb/bDY5fWme0tTeTkU14T1Gmcs17YIjB/teiDe4uaCghbYqQ==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.6.0.tgz", + "integrity": "sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.2", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@discordjs/rest/node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "0.74.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.74.2.tgz", + "integrity": "sha512-RPlB3bi2z+VQK0HRhBK73kXOQI1fFmRgWzzT6+ihB7JYfh29jb7eAfYvpx8rlf248gUAYaLQ8JYa+43l09rPmQ==", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.74.2", + "ignore": "^7.0.5", + "typebox": "^1.1.24", + "yaml": "^2.8.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.74.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.74.2.tgz", + "integrity": "sha512-ukQBHGDm20k9ZUS2cGjNN9vDJp/48r35xmvgSx3paCaC06r2N/PLuRZoJmwQ1ZM7f8T3072odv9YPWn+77w0LA==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.91.1", + "@aws-sdk/client-bedrock-runtime": "^3.1030.0", + "@google/genai": "^1.40.0", + "@mistralai/mistralai": "^2.2.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "openai": "6.26.0", + "partial-json": "^0.1.7", + "typebox": "^1.1.24" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.74.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.74.2.tgz", + "integrity": "sha512-4Ey/ZgKw8Rq/cLhKtam7ze+LrTg+cNGRMCcAm0HCteHyVbYGbYqbbV8KzXW6fD/X64+213E9PJvIiPfpKJ1kOw==", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.74.2", + "@earendil-works/pi-ai": "^0.74.2", + "@earendil-works/pi-tui": "^0.74.2", + "@silvia-odwyer/photon-node": "^0.3.4", + "chalk": "^5.5.0", + "diff": "^8.0.2", + "glob": "^13.0.1", + "highlight.js": "^10.7.3", + "hosted-git-info": "^9.0.2", + "ignore": "^7.0.5", + "jiti": "^2.7.0", + "minimatch": "^10.2.3", + "proper-lockfile": "^4.1.2", + "typebox": "^1.1.24", + "undici": "^7.19.1", + "yaml": "^2.8.2" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=20.6.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "^0.3.6" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.74.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.74.2.tgz", + "integrity": "sha512-valQPz74qbdydRqII6t9rJ46YANMOOJeDhKm25a1ZrWvWwdjAaAEu6s3ur/LWz84Wkkwcbub2ZkVjzCZi8gFGA==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "marked": "^15.0.12" + }, + "engines": { + "node": ">=20.0.0" + }, + "optionalDependencies": { + "koffi": "^2.9.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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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/@mistralai/mistralai": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@myk-org/pi-sidecar": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@myk-org/pi-sidecar/-/pi-sidecar-1.1.0.tgz", + "integrity": "sha512-U/OHxL8PQTDao0lMRqDYBlxaoydGdyQhVy9rMP8YW3K1rBxLO09IFwTkkAdJlMPQE4GgBeVcO8fAFCR+fbJZMg==", + "license": "Apache-2.0", + "dependencies": { + "@earendil-works/pi-ai": "^0.74.0", + "@earendil-works/pi-coding-agent": "^0.74.0", + "pi-orchestrator-config": "github:myk-org/pi-config#v3.1.0", + "pi-vertex-claude": "github:myk-org/pi-vertex-claude#v0.2.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "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.25.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.25.1.tgz", + "integrity": "sha512-zpDbpXBCBsxfLtG2GEUyfgvHvSFrw5CwDZSNzL0v52gx/c3oPlPbm+7W7num8xs6vyiUBn+bvYPHcQDOXZynCQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.1.tgz", + "integrity": "sha512-TSAF5NHgxEsllbErYWbK8aLnl5L601NGc5VYJlSPsKnf3YlkhdoBN+geGcaU00oiw2OK3QO5LA3QNXiiWhCidQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.25.1", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.1.tgz", + "integrity": "sha512-96JrD1q71anokymx9Iblb+zKmNQYNstlV/25A9ZYIJ2A0rp1r7/GZAIm0bDWSmVvz3DpNOCZuabzsiL+w0UHhw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.25.1", + "@smithy/types": "^4.15.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.8.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.1.tgz", + "integrity": "sha512-emtXvoky671puri18ETf64AFIQUGIEA093F2drXpBgB0OGnBLjcwNR3CA2mYu62IAqNsS56xa5lnTxAgPq7cjw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.25.1", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.1.tgz", + "integrity": "sha512-X9rVls3En0z3NtrmguTmpRM0/NqtWUxBjal6fcAkwtsub+gOdLZ6kD+V7xhUgFMGdG14bHbZ7M5QjaRI1+DatQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.25.1", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", + "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", + "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/@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "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/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/acpx": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/acpx/-/acpx-0.8.0.tgz", + "integrity": "sha512-mI8ma3sDz8QfI2c40iiOAQ3iklpdv0XB7YFieOIcZZWw8dBTGsbFn4qqgbKQjDBX8S/yIWiKvmqi5j/L8c2kUQ==", + "license": "MIT", + "dependencies": { + "@agentclientprotocol/sdk": "^0.21.1", + "commander": "^14.0.3", + "skillflag": "^0.1.4", + "tsx": "^4.22.0", + "zod": "^4.4.3" + }, + "bin": { + "acpx": "dist/cli.js" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "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/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "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/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", + "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", + "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/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.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 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/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "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/discord-api-types": { + "version": "0.38.49", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz", + "integrity": "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.26.4", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz", + "integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/discord.js/node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "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/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "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-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "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/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gaxios": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", + "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", + "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/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz", + "integrity": "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==", + "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/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/gtoken/node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gtoken/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "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/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/koffi": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.2.tgz", + "integrity": "sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "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.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "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/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "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-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pi-orchestrator-config": { + "version": "3.1.0", + "resolved": "git+ssh://git@github.com/myk-org/pi-config.git#9bed7fe4ad1121caabd3e9c70f7b557b13d0662d", + "license": "MIT", + "dependencies": { + "acpx": "^0.8.0", + "chokidar": "^5.0.0", + "discord.js": "^14.0.0", + "ws": "^8.0.0" + } + }, + "node_modules/pi-vertex-claude": { + "name": "@myk-org/pi-vertex-claude", + "version": "0.2.1", + "resolved": "git+ssh://git@github.com/myk-org/pi-vertex-claude.git#f158c893484142d349c0df3cae1be16b74f45273", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.54.0", + "@anthropic-ai/vertex-sdk": "^0.11.4", + "partial-json": "^0.1.7" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*" + } + }, + "node_modules/pi-vertex-claude/node_modules/@anthropic-ai/sdk": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.54.0.tgz", + "integrity": "sha512-xyoCtHJnt/qg5GG6IgK+UJEndz8h8ljzt/caKXmq3LfBF81nC/BW6E4x2rOWCZcvsLyVW+e8U5mtIr6UCE/kJw==", + "license": "MIT", + "bin": { + "anthropic-ai-sdk": "bin/cli" + } + }, + "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.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "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/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "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/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/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/skillflag": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/skillflag/-/skillflag-0.1.4.tgz", + "integrity": "sha512-egFg+XCF5sloOWdtzxZivTX7n4UDj5pxQoY33wbT8h+YSDjMQJ76MZUg2rXQIBXmIDtlZhLgirS1g/3R5/qaHA==", + "license": "MIT", + "dependencies": { + "@clack/prompts": "^1.0.1", + "tar-stream": "^3.1.7" + }, + "bin": { + "skill-install": "dist/bin/skill-install.js", + "skillflag": "dist/bin/skillflag.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "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/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "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/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typebox": { + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.2.18.tgz", + "integrity": "sha512-f4MtqlWxawJjxsuT1onX3F/5fas45PXQFQ1jRDTRia2IpBirlj/Xs9PvSAsw9Mz3DADIDU1mXbzfJWSg21JbRg==", + "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": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "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/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "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/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "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/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "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" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/sidecar-helper/package.json b/sidecar-helper/package.json new file mode 100644 index 000000000..c40803f1a --- /dev/null +++ b/sidecar-helper/package.json @@ -0,0 +1,17 @@ +{ + "name": "webhook-server-sidecar", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node dist/server.js", + "build": "tsc" + }, + "dependencies": { + "@myk-org/pi-sidecar": "^1.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + } +} diff --git a/sidecar-helper/src/server.ts b/sidecar-helper/src/server.ts new file mode 100644 index 000000000..76f10cbf4 --- /dev/null +++ b/sidecar-helper/src/server.ts @@ -0,0 +1,3 @@ +import { startSidecar } from "@myk-org/pi-sidecar"; + +startSidecar(); diff --git a/sidecar-helper/tsconfig.json b/sidecar-helper/tsconfig.json new file mode 100644 index 000000000..892a25092 --- /dev/null +++ b/sidecar-helper/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": false + }, + "include": ["src"] +} diff --git a/uv.lock b/uv.lock index bced07ff6..3fb489628 100644 --- a/uv.lock +++ b/uv.lock @@ -2,16 +2,6 @@ version = 1 revision = 3 requires-python = "==3.13.*" -[[package]] -name = "ai-cli-runner" -version = "0.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, - { name = "python-simple-logger" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d0/84/e26cac819bd930f785d66bc61ae40025399bbbd45c78448c8985431d8f09/ai_cli_runner-0.5.0.tar.gz", hash = "sha256:cb41eaa9fdf729fe33751d716b9e7b4571b348e70970f6230f5e38050ccf469c", size = 50626, upload-time = "2026-05-15T11:10:12.934Z" } - [[package]] name = "aiofiles" version = "25.1.0" @@ -378,7 +368,6 @@ name = "github-webhook-server" version = "4.0.1" source = { editable = "." } dependencies = [ - { name = "ai-cli-runner" }, { name = "aiofiles" }, { name = "asyncstdlib" }, { name = "build" }, @@ -387,6 +376,7 @@ dependencies = [ { name = "fastapi" }, { name = "fastapi-mcp" }, { name = "httpx" }, + { name = "pi-sidecar-client" }, { name = "psutil" }, { name = "pydantic" }, { name = "pygithub" }, @@ -429,7 +419,6 @@ tests = [ [package.metadata] requires-dist = [ - { name = "ai-cli-runner", specifier = ">=0.1.0" }, { name = "aiofiles", specifier = ">=24.1.0" }, { name = "asyncstdlib", specifier = ">=3.13.1" }, { name = "build", specifier = ">=1.2.2.post1" }, @@ -438,6 +427,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.115.0" }, { name = "fastapi-mcp", specifier = ">=0.4.0" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "pi-sidecar-client", specifier = ">=1.1.0" }, { name = "psutil", specifier = ">=7.0.0" }, { name = "pydantic", specifier = ">=2.8.0" }, { name = "pygithub", specifier = ">=2.4.0" }, @@ -761,6 +751,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] +[[package]] +name = "pi-sidecar-client" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "python-simple-logger" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/c3/c002e8315ad8206c5e2e5ddf3371cf4f53360962a44420d89121a6f4d003/pi_sidecar_client-1.1.0.tar.gz", hash = "sha256:e57dd1bd21f13c340bb81ceebee333fc797105e69d925947e89cd40e0604a83a", size = 12850, upload-time = "2026-05-31T15:30:24.262Z" } + [[package]] name = "pluggy" version = "1.6.0" diff --git a/webhook_server/config/schema.yaml b/webhook_server/config/schema.yaml index 301fecfb0..815b99545 100644 --- a/webhook_server/config/schema.yaml +++ b/webhook_server/config/schema.yaml @@ -17,7 +17,7 @@ $defs: description: AI CLI provider to use ai-model: type: string - description: AI model identifier (e.g., claude-opus-4-6[1m], sonnet, gemini-2.5-pro) + description: AI model identifier (e.g., claude-opus-4-6-1m, sonnet, gemini-2.5-pro) conventional-title: type: object description: | diff --git a/webhook_server/libs/ai_cli.py b/webhook_server/libs/ai_cli.py index 21305cac0..ddbb5aa59 100644 --- a/webhook_server/libs/ai_cli.py +++ b/webhook_server/libs/ai_cli.py @@ -1,32 +1,34 @@ from __future__ import annotations -from pathlib import Path from typing import Any -from ai_cli_runner import call_ai_cli as _call_ai_cli +from pi_sidecar_client import AIResult, call_ai_once -__all__ = ["call_ai_cli", "get_ai_config"] +__all__ = ["AIResult", "call_ai", "get_ai_config"] -async def call_ai_cli( +async def call_ai( prompt: str, ai_provider: str, ai_model: str, cwd: str, - cli_flags: list[str] | None = None, timeout_minutes: int | None = None, -) -> tuple[bool, str]: - """Call an AI CLI tool. Thin wrapper around ai_cli_runner.call_ai_cli. + system_prompt: str = "", + tools: list[str] | None = None, +) -> AIResult: + """Call an AI provider via pi-sidecar. Thin wrapper around pi_sidecar_client.call_ai_once. - Accepts cwd as str (matching clone_repo_dir type) and converts to Path. + Returns: + AIResult with .success, .text, and .error attributes. """ - return await _call_ai_cli( + return await call_ai_once( prompt=prompt, ai_provider=ai_provider, ai_model=ai_model, - cwd=Path(cwd), - cli_flags=cli_flags, - ai_cli_timeout=timeout_minutes, + cwd=cwd, + ai_call_timeout=timeout_minutes, + system_prompt=system_prompt, + tools=tools, ) diff --git a/webhook_server/libs/handlers/runner_handler.py b/webhook_server/libs/handlers/runner_handler.py index ebed67c99..adda0d544 100644 --- a/webhook_server/libs/handlers/runner_handler.py +++ b/webhook_server/libs/handlers/runner_handler.py @@ -16,7 +16,7 @@ from github.PullRequest import PullRequest from github.Repository import Repository -from webhook_server.libs.ai_cli import call_ai_cli, get_ai_config +from webhook_server.libs.ai_cli import call_ai, get_ai_config from webhook_server.libs.handlers.check_run_handler import CheckRunHandler, CheckRunOutput from webhook_server.libs.handlers.owners_files_handler import OwnersFileHandler from webhook_server.utils import helpers as helpers_module @@ -61,6 +61,19 @@ class CheckConfig: use_cwd: bool = False +def _count_files_changed(stat_output: str) -> int: + """Count files changed from git diff --stat output. + + Parses the summary line (e.g., '2 files changed, 15 insertions(+)'). + Returns 0 if the output is empty or unparseable. + """ + for line in reversed(stat_output.strip().splitlines()): + match = re.match(r"\s*(\d+) files? changed", line) + if match: + return int(match.group(1)) + return 0 + + class RunnerHandler: def __init__(self, github_webhook: "GithubWebhook", owners_file_handler: OwnersFileHandler | None = None): self.github_webhook = github_webhook @@ -899,14 +912,6 @@ async def _get_ai_title_suggestion( else: types_info = f"Allowed types: {', '.join(allowed_names)}" - cli_flags: list[str] = [] - if ai_provider == "claude": - cli_flags = ["--dangerously-skip-permissions"] - elif ai_provider == "gemini": - cli_flags = ["--yolo"] - elif ai_provider == "cursor": - cli_flags = ["--force"] - try: base_ref = await github_api_call( lambda: pull_request.base.ref, logger=self.logger, log_prefix=self.log_prefix @@ -930,22 +935,24 @@ async def _get_ai_title_suggestion( f"Example output: feat: add user authentication" ) - success, result = await call_ai_cli( + ai_result = await call_ai( prompt=prompt, ai_provider=ai_provider, ai_model=ai_model, cwd=worktree_path, - cli_flags=cli_flags, timeout_minutes=timeout_minutes, + tools=["read", "grep", "find", "ls"], # Read-only — AI inspects repo to suggest title ) - if success: + if ai_result.success: # Clean up the response - take first line, strip backticks/quotes - suggestion = result.strip().splitlines()[0].strip().strip("`").strip('"').strip("'") + suggestion = ai_result.text.strip().splitlines()[0].strip().strip("`").strip('"').strip("'") self.logger.info(f"{self.log_prefix} AI suggested title: {suggestion}") return suggestion - self.logger.warning(f"{self.log_prefix} AI title suggestion failed: {result}") + self.logger.warning( + f"{self.log_prefix} AI title suggestion failed: {ai_result.error or ai_result.text}" + ) return None except Exception: @@ -1155,43 +1162,87 @@ async def _resolve_cherry_pick_with_ai( worktree_path: str, git_cmd: str, github_token: str, - ) -> bool: - """Attempt to resolve cherry-pick conflicts using AI CLI. + commit_hash: str, + target_branch: str, + ) -> tuple[bool, str]: + """Attempt to resolve cherry-pick conflicts using AI. + + Args: + worktree_path: Path to the git worktree with conflicts. + git_cmd: Git command prefix for this worktree. + github_token: Token to redact from logs. + commit_hash: The commit being cherry-picked (for context gathering). + target_branch: The branch being cherry-picked onto. - Returns True if AI successfully resolved the conflicts, False otherwise. + Returns: + Tuple of (success, original_diff_stat). The diff stat is passed to + _verify_cherry_pick_scope to avoid a redundant subprocess call. """ ai_config = self.github_webhook.ai_features if not ai_config: self.logger.debug(f"{self.log_prefix} AI cherry-pick conflict resolution not enabled") - return False + return False, "" cherry_pick_ai_config = ai_config.get("resolve-cherry-pick-conflicts-with-ai") if not isinstance(cherry_pick_ai_config, dict) or not cherry_pick_ai_config.get("enabled"): self.logger.debug(f"{self.log_prefix} AI cherry-pick conflict resolution not enabled") - return False + return False, "" ai_result = get_ai_config(ai_config) if not ai_result: self.logger.debug(f"{self.log_prefix} AI features not fully configured (missing provider/model)") - return False + return False, "" ai_provider, ai_model = ai_result - cli_flags: list[str] = [] - if ai_provider == "claude": - cli_flags = ["--dangerously-skip-permissions"] - elif ai_provider == "gemini": - cli_flags = ["--yolo"] - elif ai_provider == "cursor": - cli_flags = ["--force"] + # Gather commit context for the AI prompt + rc, commit_message, _ = await run_command( + command=f"{git_cmd} log --oneline -1 {commit_hash}", + log_prefix=self.log_prefix, + redact_secrets=[github_token], + mask_sensitive=self.github_webhook.mask_sensitive, + ) + if not rc: + self.logger.warning(f"{self.log_prefix} Could not retrieve commit message for AI context") + commit_message = "" + else: + commit_message = commit_message.strip() + + rc, commit_diff_stat, _ = await run_command( + command=f"{git_cmd} diff {commit_hash}^..{commit_hash} --stat", + log_prefix=self.log_prefix, + redact_secrets=[github_token], + mask_sensitive=self.github_webhook.mask_sensitive, + ) + if not rc: + self.logger.warning(f"{self.log_prefix} Could not retrieve commit diff stat for AI context") + commit_diff_stat = "" + else: + commit_diff_stat = commit_diff_stat.strip() + + system_prompt = ( + "You are an expert software engineer resolving git cherry-pick merge conflicts. " + "You have access to bash and file editing tools. " + "Your goal is to resolve all conflicts while preserving the intent of the original commit." + ) prompt = ( "You are in a git repository with cherry-pick merge conflicts. " "Resolve ALL conflicts in ALL files.\n\n" - "How to handle each conflict type:\n" + f"## Original Commit Context\n" + f"**Commit:** `{commit_hash}`\n" + f"**Message:** {commit_message}\n" + f"**Target branch:** `{target_branch}`\n\n" + f"**Original commit changed files:**\n```\n{commit_diff_stat}\n```\n\n" + "## Instructions\n\n" + f"Run `git log --oneline -1 {commit_hash}` to understand the original intent.\n" + f"Run `git diff {commit_hash}^..{commit_hash}` to see the original changes.\n\n" + "### Conflict Resolution Rules\n" + "- **Prefer the cherry-picked changes.** Only use HEAD (target branch) when " + "the cherry-picked code references APIs/functions that don't exist on the target branch.\n" "- Standard conflict markers (<<<<<<< HEAD, =======, >>>>>>>): " - "HEAD is the target branch. Adapt the cherry-picked changes to fit " - "the target branch code.\n" + "HEAD is the target branch. Resolve in favor of the cherry-picked changes " + "unless they reference code not present on the target branch.\n" "- File 'deleted in HEAD and modified in ': This means the file " "does not exist on the target branch. If the cherry-pick is introducing " "this file to the target branch, keep the file and 'git add' it. " @@ -1199,7 +1250,7 @@ async def _resolve_cherry_pick_with_ai( "changes are not relevant, 'git rm' it.\n" "- File 'added in both' or 'renamed': Merge the content, keeping both " "sides' intent.\n\n" - "After resolving all conflicts, stage everything with 'git add' and " + "After resolving all conflicts, " "make sure the result is syntactically valid." ) @@ -1208,18 +1259,22 @@ async def _resolve_cherry_pick_with_ai( timeout_minutes = cherry_pick_ai_config.get("timeout-minutes", 10) try: - success, result = await call_ai_cli( + ai_call_result = await call_ai( prompt=prompt, ai_provider=ai_provider, ai_model=ai_model, cwd=worktree_path, - cli_flags=cli_flags, timeout_minutes=timeout_minutes, + system_prompt=system_prompt, + # Read + edit/write for conflict resolution, NO bash + tools=["read", "edit", "write", "grep", "find", "ls"], ) - if not success: - self.logger.warning(f"{self.log_prefix} AI conflict resolution failed: {result}") - return False + if not ai_call_result.success: + self.logger.warning( + f"{self.log_prefix} AI conflict resolution failed: {ai_call_result.error or ai_call_result.text}" + ) + return False, "" self.logger.info(f"{self.log_prefix} AI conflict resolution completed, finalizing cherry-pick") @@ -1232,7 +1287,7 @@ async def _resolve_cherry_pick_with_ai( ) if not rc: self.logger.error(f"{self.log_prefix} Failed to stage AI-resolved files: {err}") - return False + return False, commit_diff_stat # Check if cherry-pick is still in progress (it may have auto-completed # after staging resolved files, e.g. for modify/delete conflicts) @@ -1252,19 +1307,59 @@ async def _resolve_cherry_pick_with_ai( ) if not rc: self.logger.error(f"{self.log_prefix} cherry-pick --continue failed after AI resolution: {err}") - return False + return False, commit_diff_stat else: if err_check and "needed a single revision" not in err_check.lower(): self.logger.error(f"{self.log_prefix} Unexpected CHERRY_PICK_HEAD check error: {err_check}") - return False + return False, commit_diff_stat self.logger.info(f"{self.log_prefix} Cherry-pick already completed after staging resolved files") self.logger.info(f"{self.log_prefix} AI successfully resolved cherry-pick conflicts") - return True + return True, commit_diff_stat except Exception: self.logger.exception(f"{self.log_prefix} AI conflict resolution failed unexpectedly") - return False + return False, "" + + async def _verify_cherry_pick_scope( + self, + git_cmd: str, + github_token: str, + original_diff_stat: str, + ) -> None: + """Compare original commit scope vs cherry-picked commit scope. + + Uses the pre-fetched original_diff_stat from _resolve_cherry_pick_with_ai + to avoid a redundant subprocess call. Only runs git diff for the + cherry-picked commit. + + Logs a warning if the cherry-picked commit has significantly fewer + file changes than the original. This is informational only and + never fails the cherry-pick. + """ + try: + _, cherry_picked_stat, _ = await run_command( + command=f"{git_cmd} diff HEAD^..HEAD --stat", + log_prefix=self.log_prefix, + redact_secrets=[github_token], + mask_sensitive=self.github_webhook.mask_sensitive, + ) + + original_count = _count_files_changed(original_diff_stat) + cherry_picked_count = _count_files_changed(cherry_picked_stat) + + if original_count > 0 and cherry_picked_count < original_count: + self.logger.warning( + f"{self.log_prefix} Cherry-pick scope reduced: original commit changed " + f"{original_count} file(s), cherry-picked commit changed {cherry_picked_count} file(s)" + ) + else: + self.logger.info( + f"{self.log_prefix} Cherry-pick scope verified: original={original_count} file(s), " + f"cherry-picked={cherry_picked_count} file(s)" + ) + except Exception: + self.logger.exception(f"{self.log_prefix} Failed to verify cherry-pick scope (non-fatal)") async def cherry_pick( self, @@ -1396,13 +1491,16 @@ async def cherry_pick( # Only attempt AI resolution for actual merge conflicts is_conflict = "CONFLICT" in err or "CONFLICT" in out if is_conflict: - ai_resolved = await self._resolve_cherry_pick_with_ai( + ai_resolved, original_diff_stat = await self._resolve_cherry_pick_with_ai( worktree_path=worktree_path, git_cmd=git_cmd, github_token=github_token, + commit_hash=commit_hash, + target_branch=target_branch, ) else: ai_resolved = False + original_diff_stat = "" if not ai_resolved: # AI not configured, disabled, or failed — manual fallback output["text"] = self.check_run_handler.get_check_run_text(err=err, out=out) @@ -1440,6 +1538,14 @@ async def cherry_pick( return cherry_pick_had_conflicts = True + # Post-resolution verification: compare original vs cherry-picked commit scope + if cherry_pick_had_conflicts: + await self._verify_cherry_pick_scope( + git_cmd=git_cmd, + github_token=github_token, + original_diff_stat=original_diff_stat, + ) + # Restore original PR author on cherry-pick for DCO compliance await self._restore_original_author_for_cherry_pick( pull_request=pull_request, diff --git a/webhook_server/tests/test_runner_handler.py b/webhook_server/tests/test_runner_handler.py index fcf1cdf0c..2de2ce322 100644 --- a/webhook_server/tests/test_runner_handler.py +++ b/webhook_server/tests/test_runner_handler.py @@ -10,6 +10,7 @@ import pytest from github import GithubException +from webhook_server.libs.ai_cli import AIResult from webhook_server.libs.handlers.runner_handler import CheckConfig, RunnerHandler from webhook_server.utils.constants import ( BUILD_CONTAINER_STR, @@ -872,10 +873,10 @@ async def mock_checkout_worktree(**kwargs: Any) -> AsyncGenerator[tuple[bool, st runner_handler.check_run_handler, "set_check_failure", new=AsyncMock() ) as mock_set_failure: with patch( - "webhook_server.libs.handlers.runner_handler.call_ai_cli", + "webhook_server.libs.handlers.runner_handler.call_ai", new_callable=AsyncMock, - return_value=(True, "fix: correct the bad title"), - ) as mock_ai_cli: + return_value=AIResult(success=True, text="fix: correct the bad title"), + ) as mock_ai: with patch.object(runner_handler, "_checkout_worktree", side_effect=mock_checkout_worktree): await runner_handler.run_conventional_title_check(mock_pull_request) @@ -884,9 +885,9 @@ async def mock_checkout_worktree(**kwargs: Any) -> AsyncGenerator[tuple[bool, st assert "AI-Suggested Title" in output["text"] assert "fix: correct the bad title" in output["text"] - # Verify cwd was passed to call_ai_cli as worktree path - mock_ai_cli.assert_awaited_once() - call_kwargs = mock_ai_cli.call_args[1] + # Verify cwd was passed to call_ai as worktree path + mock_ai.assert_awaited_once() + call_kwargs = mock_ai.call_args[1] assert call_kwargs["cwd"] == worktree_path assert call_kwargs["timeout_minutes"] == 10 @@ -942,9 +943,9 @@ async def mock_checkout_worktree(**kwargs: Any) -> AsyncGenerator[tuple[bool, st runner_handler.check_run_handler, "set_check_failure", new=AsyncMock() ) as mock_set_failure: with patch( - "webhook_server.libs.handlers.runner_handler.call_ai_cli", + "webhook_server.libs.handlers.runner_handler.call_ai", new_callable=AsyncMock, - return_value=(True, "fix: correct the title"), + return_value=AIResult(success=True, text="fix: correct the title"), ): with patch.object(runner_handler, "_checkout_worktree", side_effect=mock_checkout_worktree): with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_to_thread: @@ -987,9 +988,9 @@ async def mock_checkout_worktree(**kwargs: Any) -> AsyncGenerator[tuple[bool, st runner_handler.check_run_handler, "set_check_failure", new=AsyncMock() ) as mock_set_failure: with patch( - "webhook_server.libs.handlers.runner_handler.call_ai_cli", + "webhook_server.libs.handlers.runner_handler.call_ai", new_callable=AsyncMock, - return_value=(True, "fix: correct the title"), + return_value=AIResult(success=True, text="fix: correct the title"), ): with patch.object(runner_handler, "_checkout_worktree", side_effect=mock_checkout_worktree): # Make edit raise an exception @@ -1032,7 +1033,7 @@ async def test_conventional_title_disabled_mode( runner_handler.check_run_handler, "set_check_failure", new=AsyncMock() ) as mock_set_failure: with patch( - "webhook_server.libs.handlers.runner_handler.call_ai_cli", + "webhook_server.libs.handlers.runner_handler.call_ai", new_callable=AsyncMock, ) as mock_ai: await runner_handler.run_conventional_title_check(mock_pull_request) @@ -1043,9 +1044,7 @@ async def test_conventional_title_disabled_mode( assert "AI-Suggested Title" not in output["text"] @pytest.mark.asyncio - async def test_conventional_title_ai_cli_failure( - self, runner_handler: RunnerHandler, mock_pull_request: Mock - ) -> None: + async def test_conventional_title_ai_failure(self, runner_handler: RunnerHandler, mock_pull_request: Mock) -> None: """Test that AI CLI failure doesn't break the check flow.""" runner_handler.github_webhook.conventional_title = "feat,fix" mock_pull_request.title = "bad title" @@ -1069,9 +1068,9 @@ async def mock_checkout_worktree(**kwargs: Any) -> AsyncGenerator[tuple[bool, st runner_handler.check_run_handler, "set_check_failure", new=AsyncMock() ) as mock_set_failure: with patch( - "webhook_server.libs.handlers.runner_handler.call_ai_cli", + "webhook_server.libs.handlers.runner_handler.call_ai", new_callable=AsyncMock, - return_value=(False, "CLI timeout"), + return_value=AIResult(success=False, text="CLI timeout"), ): with patch.object(runner_handler, "_checkout_worktree", side_effect=mock_checkout_worktree): await runner_handler.run_conventional_title_check(mock_pull_request) @@ -1080,7 +1079,7 @@ async def mock_checkout_worktree(**kwargs: Any) -> AsyncGenerator[tuple[bool, st assert "AI-Suggested Title" not in output["text"] @pytest.mark.asyncio - async def test_conventional_title_ai_cli_exception( + async def test_conventional_title_ai_exception( self, runner_handler: RunnerHandler, mock_pull_request: Mock ) -> None: """Test that AI CLI exception doesn't break the check flow.""" @@ -1106,7 +1105,7 @@ async def mock_checkout_worktree(**kwargs: Any) -> AsyncGenerator[tuple[bool, st runner_handler.check_run_handler, "set_check_failure", new=AsyncMock() ) as mock_set_failure: with patch( - "webhook_server.libs.handlers.runner_handler.call_ai_cli", + "webhook_server.libs.handlers.runner_handler.call_ai", new_callable=AsyncMock, side_effect=RuntimeError("boom"), ): @@ -1748,9 +1747,9 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st new=AsyncMock(side_effect=run_command_side_effect), ) as mock_run_cmd: with patch( - "webhook_server.libs.handlers.runner_handler.call_ai_cli", - new=AsyncMock(return_value=(True, "resolved")), - ) as mock_ai_cli: + "webhook_server.libs.handlers.runner_handler.call_ai", + new=AsyncMock(return_value=AIResult(success=True, text="resolved")), + ) as mock_ai: with patch( "asyncio.to_thread", new=AsyncMock(side_effect=lambda fn, *a, **kw: fn(*a, **kw) if a or kw else fn()), @@ -1762,12 +1761,23 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st await runner_handler.cherry_pick(mock_pull_request, "main") mock_restore_author.assert_awaited_once() mock_set_success.assert_called_once() - mock_ai_cli.assert_called_once() + mock_ai.assert_called_once() # Verify prompt includes delete/modify conflict guidance - ai_prompt = str(mock_ai_cli.call_args) + ai_prompt = str(mock_ai.call_args) assert "deleted in HEAD and modified in" in ai_prompt, ( "AI prompt should include delete/modify conflict guidance" ) + # Verify commit context is in the prompt + assert "abc123" in ai_prompt, "AI prompt should include commit hash" + assert "target_branch" in str(mock_ai.call_args) or "main" in ai_prompt, ( + "AI prompt should include target branch" + ) + # Verify system_prompt and tools are passed + call_kwargs = mock_ai.call_args[1] + assert call_kwargs.get("system_prompt"), ( + "system_prompt should be passed to call_ai" + ) + assert call_kwargs.get("tools"), "tools should be passed to call_ai" # Verify AI comment was posted comment_calls = mock_pull_request.create_issue_comment.call_args_list ai_comment = any( @@ -1827,9 +1837,9 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st new=AsyncMock(side_effect=run_command_side_effect), ) as mock_run_cmd: with patch( - "webhook_server.libs.handlers.runner_handler.call_ai_cli", - new=AsyncMock(return_value=(True, "resolved")), - ) as mock_ai_cli: + "webhook_server.libs.handlers.runner_handler.call_ai", + new=AsyncMock(return_value=AIResult(success=True, text="resolved")), + ) as mock_ai: with patch( "asyncio.to_thread", new=AsyncMock(side_effect=lambda fn, *a, **kw: fn(*a, **kw) if a or kw else fn()), @@ -1841,7 +1851,7 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st await runner_handler.cherry_pick(mock_pull_request, "main") mock_restore_author.assert_awaited_once() mock_set_success.assert_called_once() - mock_ai_cli.assert_called_once() + mock_ai.assert_called_once() # Verify cherry-pick --continue was NOT called continue_calls = [ c for c in mock_run_cmd.call_args_list if "cherry-pick --continue" in str(c) @@ -1914,9 +1924,9 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st new=AsyncMock(side_effect=run_command_side_effect), ) as mock_run_cmd: with patch( - "webhook_server.libs.handlers.runner_handler.call_ai_cli", - new=AsyncMock(return_value=(True, "resolved")), - ) as mock_ai_cli: + "webhook_server.libs.handlers.runner_handler.call_ai", + new=AsyncMock(return_value=AIResult(success=True, text="resolved")), + ) as mock_ai: with patch( "asyncio.to_thread", new=AsyncMock(side_effect=lambda fn, *a, **kw: fn(*a, **kw) if a or kw else fn()), @@ -1927,7 +1937,7 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st ): await runner_handler.cherry_pick(mock_pull_request, "main") mock_set_failure.assert_called() - mock_ai_cli.assert_called_once() + mock_ai.assert_called_once() # Verify --allow-empty was NOT called allow_empty_calls = [ c for c in mock_run_cmd.call_args_list if "commit --allow-empty" in str(c) @@ -1970,8 +1980,8 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st new=AsyncMock(side_effect=run_command_side_effect), ): with patch( - "webhook_server.libs.handlers.runner_handler.call_ai_cli", - new=AsyncMock(return_value=(False, "AI failed")), + "webhook_server.libs.handlers.runner_handler.call_ai", + new=AsyncMock(return_value=AIResult(success=False, text="AI failed")), ): with patch( "asyncio.to_thread", @@ -1989,7 +1999,7 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st async def test_cherry_pick_ai_not_configured_fallback( self, runner_handler: RunnerHandler, mock_pull_request: Mock ) -> None: - """Cherry-pick conflicts + AI not configured — manual fallback, call_ai_cli not called.""" + """Cherry-pick conflicts + AI not configured — manual fallback, call_ai not called.""" runner_handler.github_webhook.ai_features = None async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, str, str]: @@ -2011,15 +2021,15 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st new=AsyncMock(side_effect=run_command_side_effect), ): with patch( - "webhook_server.libs.handlers.runner_handler.call_ai_cli", - ) as mock_ai_cli: + "webhook_server.libs.handlers.runner_handler.call_ai", + ) as mock_ai: with patch( "asyncio.to_thread", new=AsyncMock(side_effect=lambda fn, *a, **kw: fn(*a, **kw) if a or kw else fn()), ): await runner_handler.cherry_pick(mock_pull_request, "main") mock_set_failure.assert_called() - mock_ai_cli.assert_not_called() + mock_ai.assert_not_called() @pytest.mark.asyncio async def test_cherry_pick_ai_feature_disabled_fallback( @@ -2051,15 +2061,15 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st new=AsyncMock(side_effect=run_command_side_effect), ): with patch( - "webhook_server.libs.handlers.runner_handler.call_ai_cli", - ) as mock_ai_cli: + "webhook_server.libs.handlers.runner_handler.call_ai", + ) as mock_ai: with patch( "asyncio.to_thread", new=AsyncMock(side_effect=lambda fn, *a, **kw: fn(*a, **kw) if a or kw else fn()), ): await runner_handler.cherry_pick(mock_pull_request, "main") mock_set_failure.assert_called() - mock_ai_cli.assert_not_called() + mock_ai.assert_not_called() @pytest.mark.asyncio async def test_cherry_pick_ai_resolved_mentions_pr_author( @@ -2084,7 +2094,7 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st with patch.object( runner_handler, "_resolve_cherry_pick_with_ai", - new=AsyncMock(return_value=True), + new=AsyncMock(return_value=(True, " 2 files changed")), ): await runner_handler.cherry_pick(mock_pull_request, "main") mocks.comment.assert_called() @@ -2120,7 +2130,7 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st with patch.object( runner_handler, "_resolve_cherry_pick_with_ai", - new=AsyncMock(return_value=True), + new=AsyncMock(return_value=(True, " 2 files changed")), ): await runner_handler.cherry_pick(mock_pull_request, "main") cherry_pick_pr.create_issue_comment.assert_called() @@ -2157,12 +2167,130 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st with patch.object( runner_handler, "_resolve_cherry_pick_with_ai", - new=AsyncMock(return_value=True), + new=AsyncMock(return_value=(True, " 2 files changed")), ): await runner_handler.cherry_pick(mock_pull_request, "main") mocks.set_success.assert_called_once() mocks.comment.assert_called() + @pytest.mark.asyncio + async def test_cherry_pick_ai_passes_commit_hash_and_target_branch( + self, runner_handler: RunnerHandler, mock_pull_request: Mock + ) -> None: + """Verify _resolve_cherry_pick_with_ai receives commit_hash and target_branch.""" + runner_handler.github_webhook.ai_features = { + "ai-provider": "claude", + "ai-model": "sonnet", + "resolve-cherry-pick-conflicts-with-ai": {"enabled": True}, + } + + async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, str, str]: + if "cherry-pick" in command and "--continue" not in command and "rev-parse" not in command: + return (False, "", "CONFLICT (content): Merge conflict in file.py") + if "gh pr create" in command: + return (True, "https://github.com/test-org/test-repo/pull/99", "") + return (True, "success", "") + + with ( + patch.object(runner_handler, "_restore_original_author_for_cherry_pick", new=AsyncMock(return_value=False)), + patch.object(runner_handler, "is_branch_exists", new=AsyncMock(return_value=Mock())), + patch.object(runner_handler.check_run_handler, "set_check_in_progress"), + patch.object(runner_handler.check_run_handler, "set_check_success"), + patch.object( + runner_handler, "_resolve_cherry_pick_with_ai", new=AsyncMock(return_value=(True, " 2 files changed")) + ) as mock_resolve_ai, + patch.object(runner_handler, "_checkout_worktree") as mock_checkout, + patch( + "webhook_server.libs.handlers.runner_handler.run_command", + new=AsyncMock(side_effect=run_command_side_effect), + ), + patch( + "asyncio.to_thread", + new=AsyncMock(side_effect=lambda fn, *a, **kw: fn(*a, **kw) if a or kw else fn()), + ), + patch( + "webhook_server.libs.handlers.runner_handler.get_repository_github_app_token", + return_value=None, + ), + ): + mock_checkout.return_value = AsyncMock() + mock_checkout.return_value.__aenter__ = AsyncMock(return_value=(True, "/tmp/worktree-path", "", "")) + mock_checkout.return_value.__aexit__ = AsyncMock(return_value=None) + + await runner_handler.cherry_pick(mock_pull_request, "release-v2") + + mock_resolve_ai.assert_awaited_once() + call_kwargs = mock_resolve_ai.call_args[1] + assert call_kwargs["commit_hash"] == "abc123", ( + f"Expected commit_hash='abc123', got '{call_kwargs.get('commit_hash')}'" + ) + assert call_kwargs["target_branch"] == "release-v2", ( + f"Expected target_branch='release-v2', got '{call_kwargs.get('target_branch')}'" + ) + + @pytest.mark.asyncio + async def test_cherry_pick_post_resolution_verification_logs_warning( + self, runner_handler: RunnerHandler, mock_pull_request: Mock + ) -> None: + """Verify post-resolution verification logs a warning when scope is reduced.""" + runner_handler.github_webhook.ai_features = { + "ai-provider": "claude", + "ai-model": "sonnet", + "resolve-cherry-pick-conflicts-with-ai": {"enabled": True}, + } + + # _resolve_cherry_pick_with_ai is fully mocked — it returns the original diff stat. + # _verify_cherry_pick_scope is NOT mocked — it runs and calls run_command for HEAD diff. + original_stat = " file1.py | 10 +\n file2.py | 5 +\n 2 files changed, 15 insertions(+)" + + async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, str, str]: + if "cherry-pick" in command and "--continue" not in command and "rev-parse" not in command: + return (False, "", "CONFLICT (content): Merge conflict in file.py") + if "gh pr create" in command: + return (True, "https://github.com/test-org/test-repo/pull/99", "") + # Verification: cherry-picked commit has fewer files than original + if "diff HEAD^..HEAD --stat" in command: + return (True, " file1.py | 8 +\n 1 file changed, 8 insertions(+)", "") + return (True, "success", "") + + with ( + patch.object(runner_handler, "_restore_original_author_for_cherry_pick", new=AsyncMock(return_value=False)), + patch.object(runner_handler, "is_branch_exists", new=AsyncMock(return_value=Mock())), + patch.object(runner_handler.check_run_handler, "set_check_in_progress"), + patch.object(runner_handler.check_run_handler, "set_check_success"), + patch.object( + runner_handler, + "_resolve_cherry_pick_with_ai", + new=AsyncMock(return_value=(True, original_stat)), + ), + patch.object(runner_handler, "_checkout_worktree") as mock_checkout, + patch( + "webhook_server.libs.handlers.runner_handler.run_command", + new=AsyncMock(side_effect=run_command_side_effect), + ), + patch( + "asyncio.to_thread", + new=AsyncMock(side_effect=lambda fn, *a, **kw: fn(*a, **kw) if a or kw else fn()), + ), + patch( + "webhook_server.libs.handlers.runner_handler.get_repository_github_app_token", + return_value=None, + ), + ): + mock_checkout.return_value = AsyncMock() + mock_checkout.return_value.__aenter__ = AsyncMock(return_value=(True, "/tmp/worktree-path", "", "")) + mock_checkout.return_value.__aexit__ = AsyncMock(return_value=None) + + # Should not raise — verification is informational only + with patch.object(runner_handler.logger, "warning") as mock_warn: + await runner_handler.cherry_pick(mock_pull_request, "main") + + # Assert the scope-reduction warning was logged + warning_calls = [str(c) for c in mock_warn.call_args_list] + assert any("Cherry-pick scope reduced" in w for w in warning_calls), ( + f"Expected 'Cherry-pick scope reduced' warning, got: {warning_calls}" + ) + class TestRestoreOriginalAuthorForCherryPick: """Test suite for _restore_original_author_for_cherry_pick method.""" From 4b6d9eaecb595c9f44c70fa82bb931c7e7cf0ffb Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Sat, 20 Jun 2026 17:09:14 +0300 Subject: [PATCH 02/13] fix: address Qodo review findings for pi-sidecar migration - Remove git command instructions from AI prompt (no bash access) - Update system_prompt to reference file reading/editing tools - Add PR title to cherry-pick conflict resolution prompt - Add diff verification instruction to prompt - Change git add -u to git add -A (stage new files) - Check run_command rc in _verify_cherry_pick_scope - Narrow exception from Exception to (OSError, ValueError) - Document prompt injection risk mitigation in AGENTS.md --- CLAUDE.md | 4 ++++ .../libs/handlers/runner_handler.py | 24 ++++++++++++------- webhook_server/tests/test_runner_handler.py | 3 +++ 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9d6d8d6c1..43228819b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -711,3 +711,7 @@ AI-powered enhancements controlled by `ai-features` config (global or per-repo). **Dual healthcheck:** Dockerfile `HEALTHCHECK` verifies both the webhook server (`:5000/webhook_server/healthcheck`) and the sidecar (`:${SIDECAR_PORT}/health`). Container is unhealthy if either fails. **Docker build:** Multi-stage — `sidecar-builder` stage (node:22-slim) runs `npm ci`, `npx tsc`, `npm prune --omit=dev`. Final stage copies only `dist/`, `node_modules/` (production), and `package.json`. + +**Security note:** The AI conflict resolution prompt includes commit messages from the PR +(user-controlled text). Prompt injection risk is mitigated by restricting the AI to read-only +tools plus file edit/write — no bash access. The AI cannot execute arbitrary commands. diff --git a/webhook_server/libs/handlers/runner_handler.py b/webhook_server/libs/handlers/runner_handler.py index adda0d544..c91a109cc 100644 --- a/webhook_server/libs/handlers/runner_handler.py +++ b/webhook_server/libs/handlers/runner_handler.py @@ -1164,6 +1164,7 @@ async def _resolve_cherry_pick_with_ai( github_token: str, commit_hash: str, target_branch: str, + pr_title: str, ) -> tuple[bool, str]: """Attempt to resolve cherry-pick conflicts using AI. @@ -1222,7 +1223,7 @@ async def _resolve_cherry_pick_with_ai( system_prompt = ( "You are an expert software engineer resolving git cherry-pick merge conflicts. " - "You have access to bash and file editing tools. " + "You have access to file reading and editing tools. " "Your goal is to resolve all conflicts while preserving the intent of the original commit." ) @@ -1232,11 +1233,10 @@ async def _resolve_cherry_pick_with_ai( f"## Original Commit Context\n" f"**Commit:** `{commit_hash}`\n" f"**Message:** {commit_message}\n" - f"**Target branch:** `{target_branch}`\n\n" + f"**Target branch:** `{target_branch}`\n" + f"**PR title:** {pr_title}\n\n" f"**Original commit changed files:**\n```\n{commit_diff_stat}\n```\n\n" "## Instructions\n\n" - f"Run `git log --oneline -1 {commit_hash}` to understand the original intent.\n" - f"Run `git diff {commit_hash}^..{commit_hash}` to see the original changes.\n\n" "### Conflict Resolution Rules\n" "- **Prefer the cherry-picked changes.** Only use HEAD (target branch) when " "the cherry-picked code references APIs/functions that don't exist on the target branch.\n" @@ -1251,7 +1251,9 @@ async def _resolve_cherry_pick_with_ai( "- File 'added in both' or 'renamed': Merge the content, keeping both " "sides' intent.\n\n" "After resolving all conflicts, " - "make sure the result is syntactically valid." + "make sure the result is syntactically valid.\n\n" + "After resolving, verify your resolution preserved the original commit's intent by reading " + "the resolved files and comparing them against the original commit context above." ) self.logger.info(f"{self.log_prefix} Attempting AI conflict resolution with {ai_provider}/{ai_model}") @@ -1280,7 +1282,7 @@ async def _resolve_cherry_pick_with_ai( # Stage resolved files rc, _, err = await run_command( - command=f"{git_cmd} add -u", + command=f"{git_cmd} add -A", log_prefix=self.log_prefix, redact_secrets=[github_token], mask_sensitive=self.github_webhook.mask_sensitive, @@ -1338,12 +1340,17 @@ async def _verify_cherry_pick_scope( never fails the cherry-pick. """ try: - _, cherry_picked_stat, _ = await run_command( + rc, cherry_picked_stat, _ = await run_command( command=f"{git_cmd} diff HEAD^..HEAD --stat", log_prefix=self.log_prefix, redact_secrets=[github_token], mask_sensitive=self.github_webhook.mask_sensitive, ) + if not rc: + self.logger.warning( + f"{self.log_prefix} Could not retrieve cherry-picked diff stat for scope verification" + ) + return original_count = _count_files_changed(original_diff_stat) cherry_picked_count = _count_files_changed(cherry_picked_stat) @@ -1358,7 +1365,7 @@ async def _verify_cherry_pick_scope( f"{self.log_prefix} Cherry-pick scope verified: original={original_count} file(s), " f"cherry-picked={cherry_picked_count} file(s)" ) - except Exception: + except (OSError, ValueError): self.logger.exception(f"{self.log_prefix} Failed to verify cherry-pick scope (non-fatal)") async def cherry_pick( @@ -1497,6 +1504,7 @@ async def cherry_pick( github_token=github_token, commit_hash=commit_hash, target_branch=target_branch, + pr_title=commit_msg_striped, ) else: ai_resolved = False diff --git a/webhook_server/tests/test_runner_handler.py b/webhook_server/tests/test_runner_handler.py index 2de2ce322..3c9cb83d0 100644 --- a/webhook_server/tests/test_runner_handler.py +++ b/webhook_server/tests/test_runner_handler.py @@ -2227,6 +2227,9 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st assert call_kwargs["target_branch"] == "release-v2", ( f"Expected target_branch='release-v2', got '{call_kwargs.get('target_branch')}'" ) + assert call_kwargs["pr_title"] == "feat: Test PR", ( + f"Expected pr_title='feat: Test PR', got '{call_kwargs.get('pr_title')}'" + ) @pytest.mark.asyncio async def test_cherry_pick_post_resolution_verification_logs_warning( From a14c93d275bc640703d79689d6a4210b23fa0625 Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Sat, 20 Jun 2026 17:19:43 +0300 Subject: [PATCH 03/13] fix: update AI verification instruction to use read-only tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change cherry-pick prompt verification from comparing against stat-only context to reading resolved files directly — achievable with available read/edit tools. --- webhook_server/libs/handlers/runner_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webhook_server/libs/handlers/runner_handler.py b/webhook_server/libs/handlers/runner_handler.py index c91a109cc..a51de6df8 100644 --- a/webhook_server/libs/handlers/runner_handler.py +++ b/webhook_server/libs/handlers/runner_handler.py @@ -1252,8 +1252,8 @@ async def _resolve_cherry_pick_with_ai( "sides' intent.\n\n" "After resolving all conflicts, " "make sure the result is syntactically valid.\n\n" - "After resolving, verify your resolution preserved the original commit's intent by reading " - "the resolved files and comparing them against the original commit context above." + "After resolving, read each resolved file to verify your edits are syntactically valid " + "and semantically consistent with the original commit's purpose." ) self.logger.info(f"{self.log_prefix} Attempting AI conflict resolution with {ai_provider}/{ai_model}") From 3296133c1e37640b834a8352deb18ce179c8fc7d Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Mon, 22 Jun 2026 18:29:40 +0300 Subject: [PATCH 04/13] feat: add HTTP-backed custom git tools for AI sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add internal /internal/git-tools/run endpoint with read-only allowlist - Build custom tools (git_diff, git_log, git_show, git_status) for pi-sidecar - Conventional title: uses git_diff + git_log custom tools (no builtin tools) - Cherry-pick resolution: builtin file tools + custom git tools - No bash access anywhere — all git operations via restricted HTTP endpoint - Add 20 tests for git tools endpoint and helper --- webhook_server/app.py | 2 + webhook_server/libs/ai_cli.py | 2 + .../libs/handlers/runner_handler.py | 73 ++++- webhook_server/tests/test_git_tools.py | 254 ++++++++++++++++++ webhook_server/web/git_tools.py | 59 ++++ 5 files changed, 377 insertions(+), 13 deletions(-) create mode 100644 webhook_server/tests/test_git_tools.py create mode 100644 webhook_server/web/git_tools.py diff --git a/webhook_server/app.py b/webhook_server/app.py index e33da587e..2866afabd 100644 --- a/webhook_server/app.py +++ b/webhook_server/app.py @@ -52,6 +52,7 @@ prepare_log_prefix, ) from webhook_server.utils.structured_logger import write_webhook_log +from webhook_server.web.git_tools import router as git_tools_router from webhook_server.web.log_viewer import LogViewerController # Constants @@ -298,6 +299,7 @@ async def run_manager() -> None: FASTAPI_APP: FastAPI = FastAPI(title="webhook-server", lifespan=lifespan) +FASTAPI_APP.include_router(git_tools_router) # Mount static files static_files_path = os.path.join(os.path.dirname(__file__), "web", "static") diff --git a/webhook_server/libs/ai_cli.py b/webhook_server/libs/ai_cli.py index ddbb5aa59..d43b67ea3 100644 --- a/webhook_server/libs/ai_cli.py +++ b/webhook_server/libs/ai_cli.py @@ -15,6 +15,7 @@ async def call_ai( timeout_minutes: int | None = None, system_prompt: str = "", tools: list[str] | None = None, + custom_tools: list[dict[str, Any]] | None = None, ) -> AIResult: """Call an AI provider via pi-sidecar. Thin wrapper around pi_sidecar_client.call_ai_once. @@ -29,6 +30,7 @@ async def call_ai( ai_call_timeout=timeout_minutes, system_prompt=system_prompt, tools=tools, + custom_tools=custom_tools, ) diff --git a/webhook_server/libs/handlers/runner_handler.py b/webhook_server/libs/handlers/runner_handler.py index a51de6df8..bffed7111 100644 --- a/webhook_server/libs/handlers/runner_handler.py +++ b/webhook_server/libs/handlers/runner_handler.py @@ -61,6 +61,44 @@ class CheckConfig: use_cwd: bool = False +def _build_git_custom_tools(worktree_path: str, server_port: int = 5000) -> list[dict[str, Any]]: + """Build HTTP-backed custom tools for read-only git operations. + + These tools are executed by the pi-sidecar via HTTP calls to the + webhook server's internal git-tools endpoint. + """ + base_url = f"http://127.0.0.1:{server_port}/internal/git-tools/run" + tools: list[dict[str, Any]] = [] + + for cmd_name, description in [ + ("diff", "Run git diff to see code changes. Use '--stat' for summary, or file paths for specific files."), + ("log", "Run git log to see commit history. Use '--oneline' for compact output."), + ("show", "Run git show to inspect a commit or object."), + ("status", "Run git status to see working tree state."), + ]: + tools.append({ + "name": f"git_{cmd_name}", + "description": description, + "parameters": { + "type": "object", + "properties": { + "args": { + "type": "string", + "description": f"Arguments to pass to git {cmd_name}", + } + }, + "required": ["args"], + }, + "http": { + "method": "POST", + "url": base_url, + "body_template": {"cwd": worktree_path, "args": f"{cmd_name} {{args}}"}, + }, + }) + + return tools + + def _count_files_changed(stat_output: str) -> int: """Count files changed from git diff --stat output. @@ -923,16 +961,15 @@ async def _get_ai_title_suggestion( return None prompt = ( - "You are in a git repository checked out to a PR branch.\n" - f"Run `git diff origin/{base_ref}` to see the changes in this PR.\n" - f"Run `git log origin/{base_ref}..HEAD --oneline` to see the commit messages.\n" - f"Based on the diff and commit messages, suggest a conventional commit title.\n\n" + "Suggest a conventional commit title for this pull request.\n\n" f"Current PR title: {title}\n" - f"{types_info}\n" - f"Required format: [optional scope]: \n" - f"Output ONLY the corrected title on a single line.\n" - f"Do NOT include any explanation, reasoning, markdown, or quotes.\n" - f"Example output: feat: add user authentication" + f"{types_info}\n\n" + f"Use git_diff with args 'origin/{base_ref} --stat' to see changed files.\n" + f"Use git_log with args 'origin/{base_ref}..HEAD --oneline' to see commits.\n\n" + "Required format: [optional scope]: \n" + "Output ONLY the corrected title on a single line.\n" + "Do NOT include any explanation, reasoning, markdown, or quotes.\n" + "Example output: feat: add user authentication" ) ai_result = await call_ai( @@ -941,12 +978,22 @@ async def _get_ai_title_suggestion( ai_model=ai_model, cwd=worktree_path, timeout_minutes=timeout_minutes, - tools=["read", "grep", "find", "ls"], # Read-only — AI inspects repo to suggest title + tools=[], # No builtin tools + custom_tools=_build_git_custom_tools(worktree_path), ) if ai_result.success: - # Clean up the response - take first line, strip backticks/quotes - suggestion = ai_result.text.strip().splitlines()[0].strip().strip("`").strip('"').strip("'") + response_text = ai_result.text.strip() + # Try to extract conventional title pattern from anywhere in the response + # The AI may prepend reasoning text before the actual title + allowed_types_pattern = "|".join(re.escape(name) for name in allowed_names) + title_pattern = rf"(?:^|\n|[.!?]\s*)({allowed_types_pattern})(?:\([^)]*\))?!?:\s*\S.+" + match = re.search(title_pattern, response_text, re.IGNORECASE) + if match: + suggestion = match.group(0).strip().strip("`").strip('"').strip("'") + else: + # Fallback to first line (original behavior) + suggestion = response_text.splitlines()[0].strip().strip("`").strip('"').strip("'") self.logger.info(f"{self.log_prefix} AI suggested title: {suggestion}") return suggestion @@ -1268,8 +1315,8 @@ async def _resolve_cherry_pick_with_ai( cwd=worktree_path, timeout_minutes=timeout_minutes, system_prompt=system_prompt, - # Read + edit/write for conflict resolution, NO bash tools=["read", "edit", "write", "grep", "find", "ls"], + custom_tools=_build_git_custom_tools(worktree_path), ) if not ai_call_result.success: diff --git a/webhook_server/tests/test_git_tools.py b/webhook_server/tests/test_git_tools.py new file mode 100644 index 000000000..2d20892ae --- /dev/null +++ b/webhook_server/tests/test_git_tools.py @@ -0,0 +1,254 @@ +"""Tests for webhook_server.web.git_tools internal endpoints.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +from webhook_server.app import FASTAPI_APP +from webhook_server.libs.handlers.runner_handler import _build_git_custom_tools + + +class TestGitToolsEndpoint: + """Test suite for /internal/git-tools/run endpoint.""" + + @pytest.fixture + def client(self) -> TestClient: + return TestClient(FASTAPI_APP) + + def test_allowed_command_diff(self, client: TestClient) -> None: + with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + process = AsyncMock() + process.communicate.return_value = (b"file.py | 2 +-\n", b"") + process.returncode = 0 + mock_proc.return_value = process + + resp = client.post( + "/internal/git-tools/run", + json={"cwd": "/tmp/test-repo", "args": "diff origin/main --stat"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert "file.py" in data["output"] + + def test_allowed_command_log(self, client: TestClient) -> None: + with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + process = AsyncMock() + process.communicate.return_value = (b"abc1234 feat: add feature\n", b"") + process.returncode = 0 + mock_proc.return_value = process + + resp = client.post( + "/internal/git-tools/run", + json={"cwd": "/tmp/test-repo", "args": "log origin/main..HEAD --oneline"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert "feat: add feature" in data["output"] + + def test_allowed_command_show(self, client: TestClient) -> None: + with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + process = AsyncMock() + process.communicate.return_value = (b"commit abc1234\nAuthor: test\n", b"") + process.returncode = 0 + mock_proc.return_value = process + + resp = client.post( + "/internal/git-tools/run", + json={"cwd": "/tmp/test-repo", "args": "show HEAD"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + + def test_allowed_command_status(self, client: TestClient) -> None: + with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + process = AsyncMock() + process.communicate.return_value = (b"On branch main\nnothing to commit\n", b"") + process.returncode = 0 + mock_proc.return_value = process + + resp = client.post( + "/internal/git-tools/run", + json={"cwd": "/tmp/test-repo", "args": "status"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + + def test_allowed_command_rev_parse(self, client: TestClient) -> None: + with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + process = AsyncMock() + process.communicate.return_value = (b"abc1234def5678\n", b"") + process.returncode = 0 + mock_proc.return_value = process + + resp = client.post( + "/internal/git-tools/run", + json={"cwd": "/tmp/test-repo", "args": "rev-parse HEAD"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + + def test_blocked_command_push(self, client: TestClient) -> None: + resp = client.post( + "/internal/git-tools/run", + json={"cwd": "/tmp/test-repo", "args": "push origin main"}, + ) + assert resp.status_code == 403 + assert "push" in resp.json()["detail"] + assert "not allowed" in resp.json()["detail"] + + def test_blocked_command_checkout(self, client: TestClient) -> None: + resp = client.post( + "/internal/git-tools/run", + json={"cwd": "/tmp/test-repo", "args": "checkout main"}, + ) + assert resp.status_code == 403 + + def test_blocked_command_reset(self, client: TestClient) -> None: + resp = client.post( + "/internal/git-tools/run", + json={"cwd": "/tmp/test-repo", "args": "reset --hard HEAD~1"}, + ) + assert resp.status_code == 403 + + def test_blocked_command_rm(self, client: TestClient) -> None: + resp = client.post( + "/internal/git-tools/run", + json={"cwd": "/tmp/test-repo", "args": "rm file.py"}, + ) + assert resp.status_code == 403 + + def test_empty_args(self, client: TestClient) -> None: + resp = client.post( + "/internal/git-tools/run", + json={"cwd": "/tmp/test-repo", "args": ""}, + ) + assert resp.status_code == 400 + assert "Empty" in resp.json()["detail"] + + def test_git_command_failure_returns_stderr(self, client: TestClient) -> None: + with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + process = AsyncMock() + process.communicate.return_value = (b"", b"fatal: not a git repository\n") + process.returncode = 128 + mock_proc.return_value = process + + resp = client.post( + "/internal/git-tools/run", + json={"cwd": "/tmp/not-a-repo", "args": "diff HEAD"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False + assert "not a git repository" in data["output"] + + def test_timeout(self, client: TestClient) -> None: + with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + process = AsyncMock() + process.communicate.side_effect = TimeoutError() + mock_proc.return_value = process + + resp = client.post( + "/internal/git-tools/run", + json={"cwd": "/tmp/test-repo", "args": "diff HEAD"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False + assert "timed out" in data["output"] + + def test_output_capped_at_50k(self, client: TestClient) -> None: + with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + process = AsyncMock() + large_output = b"x" * 100_000 + process.communicate.return_value = (large_output, b"") + process.returncode = 0 + mock_proc.return_value = process + + resp = client.post( + "/internal/git-tools/run", + json={"cwd": "/tmp/test-repo", "args": "diff HEAD"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert len(data["output"]) == 50_000 + + def test_subprocess_exception(self, client: TestClient) -> None: + with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + mock_proc.side_effect = OSError("No such file or directory") + + resp = client.post( + "/internal/git-tools/run", + json={"cwd": "/tmp/test-repo", "args": "diff HEAD"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False + assert "No such file" in data["output"] + + def test_cwd_is_shell_quoted(self, client: TestClient) -> None: + """Verify cwd with spaces is safely handled.""" + with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + process = AsyncMock() + process.communicate.return_value = (b"ok\n", b"") + process.returncode = 0 + mock_proc.return_value = process + + resp = client.post( + "/internal/git-tools/run", + json={"cwd": "/tmp/path with spaces/repo", "args": "status"}, + ) + + assert resp.status_code == 200 + # Verify the command was called with properly quoted cwd + call_args = mock_proc.call_args[0][0] + assert "'/tmp/path with spaces/repo'" in call_args + + +class TestBuildGitCustomTools: + """Test suite for _build_git_custom_tools helper.""" + + def test_builds_four_tools(self) -> None: + tools = _build_git_custom_tools("/tmp/wt") + assert len(tools) == 4 + names = [t["name"] for t in tools] + assert names == ["git_diff", "git_log", "git_show", "git_status"] + + def test_tool_structure(self) -> None: + tools = _build_git_custom_tools("/tmp/my-worktree") + tool = tools[0] # git_diff + assert tool["name"] == "git_diff" + assert "description" in tool + assert tool["parameters"]["type"] == "object" + assert "args" in tool["parameters"]["properties"] + assert tool["parameters"]["required"] == ["args"] + assert tool["http"]["method"] == "POST" + assert tool["http"]["url"] == "http://127.0.0.1:5000/internal/git-tools/run" + assert tool["http"]["body_template"]["cwd"] == "/tmp/my-worktree" + assert "diff" in tool["http"]["body_template"]["args"] + + def test_custom_server_port(self) -> None: + tools = _build_git_custom_tools("/tmp/wt", server_port=8080) + assert tools[0]["http"]["url"] == "http://127.0.0.1:8080/internal/git-tools/run" + + def test_worktree_path_in_body(self) -> None: + tools = _build_git_custom_tools("/data/worktrees/abc123") + for tool in tools: + assert tool["http"]["body_template"]["cwd"] == "/data/worktrees/abc123" diff --git a/webhook_server/web/git_tools.py b/webhook_server/web/git_tools.py new file mode 100644 index 000000000..69c0ddd21 --- /dev/null +++ b/webhook_server/web/git_tools.py @@ -0,0 +1,59 @@ +"""Internal HTTP endpoints for AI custom git tools. + +These endpoints are called by the pi-sidecar as HTTP-backed custom tools +during AI sessions. They execute read-only git commands in a specified directory. + +SECURITY: Bound to 127.0.0.1 only. Restricted to read-only git subcommands. +""" + +from __future__ import annotations + +import asyncio +import shlex + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +ALLOWED_GIT_COMMANDS = frozenset({"diff", "log", "show", "status", "rev-parse"}) + +router = APIRouter(prefix="/internal/git-tools", tags=["internal"]) + + +class GitCommandRequest(BaseModel): + cwd: str + args: str + + +class GitCommandResponse(BaseModel): + success: bool + output: str + + +@router.post("/run") +async def run_git_command(request: GitCommandRequest) -> GitCommandResponse: + """Execute a read-only git command in the specified directory.""" + parts = shlex.split(request.args) + if not parts: + raise HTTPException(status_code=400, detail="Empty git command") + + subcommand = parts[0] + if subcommand not in ALLOWED_GIT_COMMANDS: + raise HTTPException( + status_code=403, + detail=f"Git subcommand '{subcommand}' not allowed. Allowed: {', '.join(sorted(ALLOWED_GIT_COMMANDS))}", + ) + + cmd = f"git -C {shlex.quote(request.cwd)} {request.args}" + try: + proc = await asyncio.create_subprocess_shell( + cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) + output = stdout.decode() if proc.returncode == 0 else stderr.decode() + return GitCommandResponse(success=proc.returncode == 0, output=output[:50000]) + except TimeoutError: + return GitCommandResponse(success=False, output="Command timed out after 30s") + except Exception as ex: + return GitCommandResponse(success=False, output=str(ex)) From 657af2dddb8c4b3aff61605096919c9d01c7a66a Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Mon, 22 Jun 2026 19:30:47 +0300 Subject: [PATCH 05/13] fix: address Qodo review findings for custom git tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix shell injection: create_subprocess_shell → create_subprocess_exec - Fix timeout subprocess leak: kill + wait on timeout - Narrow exception: Exception → OSError - Read server port from config instead of hardcoding 5000 - Handle wildcard mode in AI title regex extraction - Add security comment for internal git-tools endpoint - Update test mocks for subprocess_exec --- webhook_server/app.py | 2 + .../libs/handlers/runner_handler.py | 15 +++-- webhook_server/tests/test_git_tools.py | 59 ++++++++++++------- webhook_server/web/git_tools.py | 27 +++++++-- 4 files changed, 72 insertions(+), 31 deletions(-) diff --git a/webhook_server/app.py b/webhook_server/app.py index 2866afabd..befd58dd2 100644 --- a/webhook_server/app.py +++ b/webhook_server/app.py @@ -299,6 +299,8 @@ async def run_manager() -> None: FASTAPI_APP: FastAPI = FastAPI(title="webhook-server", lifespan=lifespan) +# SECURITY: git-tools endpoints are internal (called by pi-sidecar on 127.0.0.1). +# Deploy behind a reverse proxy or firewall in production — same as log viewer endpoints. FASTAPI_APP.include_router(git_tools_router) # Mount static files diff --git a/webhook_server/libs/handlers/runner_handler.py b/webhook_server/libs/handlers/runner_handler.py index bffed7111..c415498a2 100644 --- a/webhook_server/libs/handlers/runner_handler.py +++ b/webhook_server/libs/handlers/runner_handler.py @@ -17,6 +17,7 @@ from github.Repository import Repository from webhook_server.libs.ai_cli import call_ai, get_ai_config +from webhook_server.libs.config import Config from webhook_server.libs.handlers.check_run_handler import CheckRunHandler, CheckRunOutput from webhook_server.libs.handlers.owners_files_handler import OwnersFileHandler from webhook_server.utils import helpers as helpers_module @@ -61,12 +62,14 @@ class CheckConfig: use_cwd: bool = False -def _build_git_custom_tools(worktree_path: str, server_port: int = 5000) -> list[dict[str, Any]]: +def _build_git_custom_tools(worktree_path: str, server_port: int | None = None) -> list[dict[str, Any]]: """Build HTTP-backed custom tools for read-only git operations. These tools are executed by the pi-sidecar via HTTP calls to the webhook server's internal git-tools endpoint. """ + if server_port is None: + server_port = int(Config().root_data.get("port", 5000)) base_url = f"http://127.0.0.1:{server_port}/internal/git-tools/run" tools: list[dict[str, Any]] = [] @@ -986,11 +989,15 @@ async def _get_ai_title_suggestion( response_text = ai_result.text.strip() # Try to extract conventional title pattern from anywhere in the response # The AI may prepend reasoning text before the actual title - allowed_types_pattern = "|".join(re.escape(name) for name in allowed_names) - title_pattern = rf"(?:^|\n|[.!?]\s*)({allowed_types_pattern})(?:\([^)]*\))?!?:\s*\S.+" + if is_wildcard or not allowed_names: + # Wildcard mode: match any word followed by optional scope and colon + title_pattern = r"(?:^|\n|[.!?]\s*)(\w+(?:\([^)]*\))?!?:\s*\S.+)" + else: + allowed_types_pattern = "|".join(re.escape(name) for name in allowed_names) + title_pattern = rf"(?:^|\n|[.!?]\s*)((?:{allowed_types_pattern})(?:\([^)]*\))?!?:\s*\S.+)" match = re.search(title_pattern, response_text, re.IGNORECASE) if match: - suggestion = match.group(0).strip().strip("`").strip('"').strip("'") + suggestion = match.group(1).strip().strip("`").strip('"').strip("'") else: # Fallback to first line (original behavior) suggestion = response_text.splitlines()[0].strip().strip("`").strip('"').strip("'") diff --git a/webhook_server/tests/test_git_tools.py b/webhook_server/tests/test_git_tools.py index 2d20892ae..dad98b9a1 100644 --- a/webhook_server/tests/test_git_tools.py +++ b/webhook_server/tests/test_git_tools.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from fastapi.testclient import TestClient @@ -10,6 +10,8 @@ from webhook_server.app import FASTAPI_APP from webhook_server.libs.handlers.runner_handler import _build_git_custom_tools +MOCK_TARGET = "webhook_server.web.git_tools.asyncio.create_subprocess_exec" + class TestGitToolsEndpoint: """Test suite for /internal/git-tools/run endpoint.""" @@ -19,7 +21,7 @@ def client(self) -> TestClient: return TestClient(FASTAPI_APP) def test_allowed_command_diff(self, client: TestClient) -> None: - with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"file.py | 2 +-\n", b"") process.returncode = 0 @@ -36,7 +38,7 @@ def test_allowed_command_diff(self, client: TestClient) -> None: assert "file.py" in data["output"] def test_allowed_command_log(self, client: TestClient) -> None: - with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"abc1234 feat: add feature\n", b"") process.returncode = 0 @@ -53,7 +55,7 @@ def test_allowed_command_log(self, client: TestClient) -> None: assert "feat: add feature" in data["output"] def test_allowed_command_show(self, client: TestClient) -> None: - with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"commit abc1234\nAuthor: test\n", b"") process.returncode = 0 @@ -69,7 +71,7 @@ def test_allowed_command_show(self, client: TestClient) -> None: assert data["success"] is True def test_allowed_command_status(self, client: TestClient) -> None: - with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"On branch main\nnothing to commit\n", b"") process.returncode = 0 @@ -85,7 +87,7 @@ def test_allowed_command_status(self, client: TestClient) -> None: assert data["success"] is True def test_allowed_command_rev_parse(self, client: TestClient) -> None: - with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"abc1234def5678\n", b"") process.returncode = 0 @@ -139,7 +141,7 @@ def test_empty_args(self, client: TestClient) -> None: assert "Empty" in resp.json()["detail"] def test_git_command_failure_returns_stderr(self, client: TestClient) -> None: - with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"", b"fatal: not a git repository\n") process.returncode = 128 @@ -155,10 +157,12 @@ def test_git_command_failure_returns_stderr(self, client: TestClient) -> None: assert data["success"] is False assert "not a git repository" in data["output"] - def test_timeout(self, client: TestClient) -> None: - with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + def test_timeout_kills_process(self, client: TestClient) -> None: + with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.side_effect = TimeoutError() + process.kill = Mock() + process.wait = AsyncMock() mock_proc.return_value = process resp = client.post( @@ -170,9 +174,10 @@ def test_timeout(self, client: TestClient) -> None: data = resp.json() assert data["success"] is False assert "timed out" in data["output"] + process.kill.assert_called_once() def test_output_capped_at_50k(self, client: TestClient) -> None: - with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() large_output = b"x" * 100_000 process.communicate.return_value = (large_output, b"") @@ -189,8 +194,8 @@ def test_output_capped_at_50k(self, client: TestClient) -> None: assert data["success"] is True assert len(data["output"]) == 50_000 - def test_subprocess_exception(self, client: TestClient) -> None: - with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + def test_oserror_exception(self, client: TestClient) -> None: + with patch(MOCK_TARGET) as mock_proc: mock_proc.side_effect = OSError("No such file or directory") resp = client.post( @@ -203,9 +208,9 @@ def test_subprocess_exception(self, client: TestClient) -> None: assert data["success"] is False assert "No such file" in data["output"] - def test_cwd_is_shell_quoted(self, client: TestClient) -> None: - """Verify cwd with spaces is safely handled.""" - with patch("webhook_server.web.git_tools.asyncio.create_subprocess_shell") as mock_proc: + def test_cwd_passed_as_exec_arg(self, client: TestClient) -> None: + """Verify cwd with spaces is passed as a separate exec argument (no shell quoting needed).""" + with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"ok\n", b"") process.returncode = 0 @@ -217,22 +222,26 @@ def test_cwd_is_shell_quoted(self, client: TestClient) -> None: ) assert resp.status_code == 200 - # Verify the command was called with properly quoted cwd - call_args = mock_proc.call_args[0][0] - assert "'/tmp/path with spaces/repo'" in call_args + # Verify cwd is passed as a separate argument (no shell quoting) + call_args = mock_proc.call_args[0] + assert call_args == ("git", "-C", "/tmp/path with spaces/repo", "status") class TestBuildGitCustomTools: """Test suite for _build_git_custom_tools helper.""" def test_builds_four_tools(self) -> None: - tools = _build_git_custom_tools("/tmp/wt") + with patch("webhook_server.libs.handlers.runner_handler.Config") as mock_config: + mock_config.return_value.root_data = {"port": 5000} + tools = _build_git_custom_tools("/tmp/wt") assert len(tools) == 4 names = [t["name"] for t in tools] assert names == ["git_diff", "git_log", "git_show", "git_status"] def test_tool_structure(self) -> None: - tools = _build_git_custom_tools("/tmp/my-worktree") + with patch("webhook_server.libs.handlers.runner_handler.Config") as mock_config: + mock_config.return_value.root_data = {"port": 5000} + tools = _build_git_custom_tools("/tmp/my-worktree") tool = tools[0] # git_diff assert tool["name"] == "git_diff" assert "description" in tool @@ -248,7 +257,15 @@ def test_custom_server_port(self) -> None: tools = _build_git_custom_tools("/tmp/wt", server_port=8080) assert tools[0]["http"]["url"] == "http://127.0.0.1:8080/internal/git-tools/run" + def test_reads_port_from_config(self) -> None: + with patch("webhook_server.libs.handlers.runner_handler.Config") as mock_config: + mock_config.return_value.root_data = {"port": 9090} + tools = _build_git_custom_tools("/tmp/wt") + assert tools[0]["http"]["url"] == "http://127.0.0.1:9090/internal/git-tools/run" + def test_worktree_path_in_body(self) -> None: - tools = _build_git_custom_tools("/data/worktrees/abc123") + with patch("webhook_server.libs.handlers.runner_handler.Config") as mock_config: + mock_config.return_value.root_data = {"port": 5000} + tools = _build_git_custom_tools("/data/worktrees/abc123") for tool in tools: assert tool["http"]["body_template"]["cwd"] == "/data/worktrees/abc123" diff --git a/webhook_server/web/git_tools.py b/webhook_server/web/git_tools.py index 69c0ddd21..d02a820fb 100644 --- a/webhook_server/web/git_tools.py +++ b/webhook_server/web/git_tools.py @@ -15,6 +15,7 @@ from pydantic import BaseModel ALLOWED_GIT_COMMANDS = frozenset({"diff", "log", "show", "status", "rev-parse"}) +BLOCKED_FLAGS = frozenset({"--no-index", "--output", "--raw"}) router = APIRouter(prefix="/internal/git-tools", tags=["internal"]) @@ -43,17 +44,31 @@ async def run_git_command(request: GitCommandRequest) -> GitCommandResponse: detail=f"Git subcommand '{subcommand}' not allowed. Allowed: {', '.join(sorted(ALLOWED_GIT_COMMANDS))}", ) - cmd = f"git -C {shlex.quote(request.cwd)} {request.args}" + for part in parts[1:]: + if part in BLOCKED_FLAGS: + raise HTTPException( + status_code=403, + detail=f"Git flag '{part}' not allowed for security reasons", + ) + + # Build argument list — no shell interpolation + cmd_args = ["git", "-C", request.cwd, *parts] + proc: asyncio.subprocess.Process | None = None try: - proc = await asyncio.create_subprocess_shell( - cmd, + proc = await asyncio.create_subprocess_exec( + *cmd_args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) - output = stdout.decode() if proc.returncode == 0 else stderr.decode() - return GitCommandResponse(success=proc.returncode == 0, output=output[:50000]) + stdout_text = stdout.decode(errors="replace") + stderr_text = stderr.decode(errors="replace") + output = stdout_text if stdout_text.strip() else stderr_text + return GitCommandResponse(success=proc.returncode == 0 or bool(stdout_text.strip()), output=output[:50000]) except TimeoutError: + if proc: + proc.kill() + await proc.wait() return GitCommandResponse(success=False, output="Command timed out after 30s") - except Exception as ex: + except OSError as ex: return GitCommandResponse(success=False, output=str(ex)) From a5d648fab6022a69077c7579542f11999f26612d Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Mon, 22 Jun 2026 20:39:09 +0300 Subject: [PATCH 06/13] fix: address remaining Qodo findings - Add sidecar healthcheck before AI calls - Add localhost-only middleware for /internal/ endpoints - Log error when sidecar fails readiness check - Cache server port in RunnerHandler (no blocking Config read) - Add git tool instructions to cherry-pick prompt --- entrypoint.sh | 4 ++ sidecar-helper/package-lock.json | 60 +++++++++---------- webhook_server/app.py | 4 +- webhook_server/libs/ai_cli.py | 6 +- .../libs/handlers/runner_handler.py | 30 ++++++++-- webhook_server/tests/test_git_tools.py | 20 ++----- webhook_server/web/git_tools.py | 30 +++++++++- 7 files changed, 99 insertions(+), 55 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index 6e7e3f458..3c87def74 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -24,6 +24,10 @@ if [ -f "$APP_DIR/sidecar-helper/dist/server.js" ]; then fi sleep 0.5 done + + if ! curl -sf http://127.0.0.1:$SIDECAR_PORT/health > /dev/null 2>&1; then + echo "[sidecar] ERROR: sidecar failed to become healthy within 15s — AI features will not work" >&2 + fi else echo "[sidecar] WARNING: sidecar-helper/dist/server.js not found, AI features will not be available" fi diff --git a/sidecar-helper/package-lock.json b/sidecar-helper/package-lock.json index 67925eba2..4047e46d7 100644 --- a/sidecar-helper/package-lock.json +++ b/sidecar-helper/package-lock.json @@ -1596,9 +1596,9 @@ "license": "Apache-2.0" }, "node_modules/@smithy/core": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.25.1.tgz", - "integrity": "sha512-zpDbpXBCBsxfLtG2GEUyfgvHvSFrw5CwDZSNzL0v52gx/c3oPlPbm+7W7num8xs6vyiUBn+bvYPHcQDOXZynCQ==", + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.26.0.tgz", + "integrity": "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", @@ -1610,12 +1610,12 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.1.tgz", - "integrity": "sha512-TSAF5NHgxEsllbErYWbK8aLnl5L601NGc5VYJlSPsKnf3YlkhdoBN+geGcaU00oiw2OK3QO5LA3QNXiiWhCidQ==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.2.tgz", + "integrity": "sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.25.1", + "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, @@ -1624,12 +1624,12 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.1.tgz", - "integrity": "sha512-96JrD1q71anokymx9Iblb+zKmNQYNstlV/25A9ZYIJ2A0rp1r7/GZAIm0bDWSmVvz3DpNOCZuabzsiL+w0UHhw==", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.2.tgz", + "integrity": "sha512-Ei/UK/QMhq0rKaMqGPlOAkE2yS9DZeYmZdk1RAKc3vp3zxgleZHZyBLlZv8yLsxljX4svCRuMTD6u3LLIcU4Bg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.25.1", + "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, @@ -1650,12 +1650,12 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.1.tgz", - "integrity": "sha512-emtXvoky671puri18ETf64AFIQUGIEA093F2drXpBgB0OGnBLjcwNR3CA2mYu62IAqNsS56xa5lnTxAgPq7cjw==", + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.2.tgz", + "integrity": "sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.25.1", + "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, @@ -1664,12 +1664,12 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.1.tgz", - "integrity": "sha512-X9rVls3En0z3NtrmguTmpRM0/NqtWUxBjal6fcAkwtsub+gOdLZ6kD+V7xhUgFMGdG14bHbZ7M5QjaRI1+DatQ==", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.2.tgz", + "integrity": "sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.25.1", + "@smithy/core": "^3.26.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, @@ -1716,9 +1716,9 @@ } }, "node_modules/@types/node": { - "version": "22.19.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", - "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2683,9 +2683,9 @@ "license": "MIT" }, "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.0.tgz", + "integrity": "sha512-e5y7RCLHKjemsgQ4eqGJtPyr10ILz25HO7flzxhTV8bgvd5yHx98DGtCAtbVW9f2TqnYI/gEVZd+vz7snrdPTw==", "funding": [ { "type": "github", @@ -2715,7 +2715,7 @@ }, "node_modules/pi-orchestrator-config": { "version": "3.1.0", - "resolved": "git+ssh://git@github.com/myk-org/pi-config.git#9bed7fe4ad1121caabd3e9c70f7b557b13d0662d", + "resolved": "git+https://github.com/myk-org/pi-config.git#9bed7fe4ad1121caabd3e9c70f7b557b13d0662d", "license": "MIT", "dependencies": { "acpx": "^0.8.0", @@ -2727,7 +2727,7 @@ "node_modules/pi-vertex-claude": { "name": "@myk-org/pi-vertex-claude", "version": "0.2.1", - "resolved": "git+ssh://git@github.com/myk-org/pi-vertex-claude.git#f158c893484142d349c0df3cae1be16b74f45273", + "resolved": "git+https://github.com/myk-org/pi-vertex-claude.git#f158c893484142d349c0df3cae1be16b74f45273", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.54.0", @@ -2961,9 +2961,9 @@ } }, "node_modules/typebox": { - "version": "1.2.18", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.2.18.tgz", - "integrity": "sha512-f4MtqlWxawJjxsuT1onX3F/5fas45PXQFQ1jRDTRia2IpBirlj/Xs9PvSAsw9Mz3DADIDU1mXbzfJWSg21JbRg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.0.tgz", + "integrity": "sha512-3HaX5iZ13wSzcLSflDH1UJwaXnRghtc8LhQtKnq8qnlcnZvmGOpBOM6rY9PdyPBKNOYBpDHfE9r6BmI41fvp4g==", "license": "MIT" }, "node_modules/typescript": { diff --git a/webhook_server/app.py b/webhook_server/app.py index befd58dd2..41ffe22b4 100644 --- a/webhook_server/app.py +++ b/webhook_server/app.py @@ -52,6 +52,7 @@ prepare_log_prefix, ) from webhook_server.utils.structured_logger import write_webhook_log +from webhook_server.web.git_tools import LocalhostOnlyMiddleware from webhook_server.web.git_tools import router as git_tools_router from webhook_server.web.log_viewer import LogViewerController @@ -299,8 +300,7 @@ async def run_manager() -> None: FASTAPI_APP: FastAPI = FastAPI(title="webhook-server", lifespan=lifespan) -# SECURITY: git-tools endpoints are internal (called by pi-sidecar on 127.0.0.1). -# Deploy behind a reverse proxy or firewall in production — same as log viewer endpoints. +FASTAPI_APP.add_middleware(LocalhostOnlyMiddleware) FASTAPI_APP.include_router(git_tools_router) # Mount static files diff --git a/webhook_server/libs/ai_cli.py b/webhook_server/libs/ai_cli.py index d43b67ea3..c4bd50528 100644 --- a/webhook_server/libs/ai_cli.py +++ b/webhook_server/libs/ai_cli.py @@ -2,7 +2,7 @@ from typing import Any -from pi_sidecar_client import AIResult, call_ai_once +from pi_sidecar_client import AIResult, call_ai_once, check_sidecar_available __all__ = ["AIResult", "call_ai", "get_ai_config"] @@ -22,6 +22,10 @@ async def call_ai( Returns: AIResult with .success, .text, and .error attributes. """ + available, msg = await check_sidecar_available() + if not available: + return AIResult(success=False, text="", error=f"Pi-sidecar unavailable: {msg}") + return await call_ai_once( prompt=prompt, ai_provider=ai_provider, diff --git a/webhook_server/libs/handlers/runner_handler.py b/webhook_server/libs/handlers/runner_handler.py index c415498a2..b5e26a8ef 100644 --- a/webhook_server/libs/handlers/runner_handler.py +++ b/webhook_server/libs/handlers/runner_handler.py @@ -17,7 +17,6 @@ from github.Repository import Repository from webhook_server.libs.ai_cli import call_ai, get_ai_config -from webhook_server.libs.config import Config from webhook_server.libs.handlers.check_run_handler import CheckRunHandler, CheckRunOutput from webhook_server.libs.handlers.owners_files_handler import OwnersFileHandler from webhook_server.utils import helpers as helpers_module @@ -62,14 +61,12 @@ class CheckConfig: use_cwd: bool = False -def _build_git_custom_tools(worktree_path: str, server_port: int | None = None) -> list[dict[str, Any]]: +def _build_git_custom_tools(worktree_path: str, server_port: int = 5000) -> list[dict[str, Any]]: """Build HTTP-backed custom tools for read-only git operations. These tools are executed by the pi-sidecar via HTTP calls to the webhook server's internal git-tools endpoint. """ - if server_port is None: - server_port = int(Config().root_data.get("port", 5000)) base_url = f"http://127.0.0.1:{server_port}/internal/git-tools/run" tools: list[dict[str, Any]] = [] @@ -128,6 +125,18 @@ def __init__(self, github_webhook: "GithubWebhook", owners_file_handler: OwnersF github_webhook=self.github_webhook, owners_file_handler=self.owners_file_handler ) + @property + def _server_port(self) -> int: + """Webhook server port for internal git-tools endpoint. + + Reads from config on each access. Falls back to 5000 if the config + value is not a valid integer (e.g., in test environments with mocks). + """ + try: + return int(self.github_webhook.config.root_data.get("port", 5000)) + except (TypeError, ValueError): + return 5000 + @contextlib.asynccontextmanager async def _checkout_worktree( self, @@ -982,7 +991,7 @@ async def _get_ai_title_suggestion( cwd=worktree_path, timeout_minutes=timeout_minutes, tools=[], # No builtin tools - custom_tools=_build_git_custom_tools(worktree_path), + custom_tools=_build_git_custom_tools(worktree_path, server_port=self._server_port), ) if ai_result.success: @@ -1290,6 +1299,9 @@ async def _resolve_cherry_pick_with_ai( f"**Target branch:** `{target_branch}`\n" f"**PR title:** {pr_title}\n\n" f"**Original commit changed files:**\n```\n{commit_diff_stat}\n```\n\n" + "Use the git_diff and git_log tools to inspect the original changes if needed.\n" + f"Run git_diff with args '{commit_hash}^..{commit_hash}' to see the original commit diff.\n" + f"Run git_log with args '--oneline -5' to see recent commit history.\n\n" "## Instructions\n\n" "### Conflict Resolution Rules\n" "- **Prefer the cherry-picked changes.** Only use HEAD (target branch) when " @@ -1323,7 +1335,7 @@ async def _resolve_cherry_pick_with_ai( timeout_minutes=timeout_minutes, system_prompt=system_prompt, tools=["read", "edit", "write", "grep", "find", "ls"], - custom_tools=_build_git_custom_tools(worktree_path), + custom_tools=_build_git_custom_tools(worktree_path, server_port=self._server_port), ) if not ai_call_result.success: @@ -1407,6 +1419,12 @@ async def _verify_cherry_pick_scope( return original_count = _count_files_changed(original_diff_stat) + if original_count == 0: + self.logger.info( + f"{self.log_prefix} Cherry-pick scope verification skipped — no original diff stat available" + ) + return + cherry_picked_count = _count_files_changed(cherry_picked_stat) if original_count > 0 and cherry_picked_count < original_count: diff --git a/webhook_server/tests/test_git_tools.py b/webhook_server/tests/test_git_tools.py index dad98b9a1..90a36f58c 100644 --- a/webhook_server/tests/test_git_tools.py +++ b/webhook_server/tests/test_git_tools.py @@ -231,17 +231,13 @@ class TestBuildGitCustomTools: """Test suite for _build_git_custom_tools helper.""" def test_builds_four_tools(self) -> None: - with patch("webhook_server.libs.handlers.runner_handler.Config") as mock_config: - mock_config.return_value.root_data = {"port": 5000} - tools = _build_git_custom_tools("/tmp/wt") + tools = _build_git_custom_tools("/tmp/wt") assert len(tools) == 4 names = [t["name"] for t in tools] assert names == ["git_diff", "git_log", "git_show", "git_status"] def test_tool_structure(self) -> None: - with patch("webhook_server.libs.handlers.runner_handler.Config") as mock_config: - mock_config.return_value.root_data = {"port": 5000} - tools = _build_git_custom_tools("/tmp/my-worktree") + tools = _build_git_custom_tools("/tmp/my-worktree") tool = tools[0] # git_diff assert tool["name"] == "git_diff" assert "description" in tool @@ -257,15 +253,11 @@ def test_custom_server_port(self) -> None: tools = _build_git_custom_tools("/tmp/wt", server_port=8080) assert tools[0]["http"]["url"] == "http://127.0.0.1:8080/internal/git-tools/run" - def test_reads_port_from_config(self) -> None: - with patch("webhook_server.libs.handlers.runner_handler.Config") as mock_config: - mock_config.return_value.root_data = {"port": 9090} - tools = _build_git_custom_tools("/tmp/wt") - assert tools[0]["http"]["url"] == "http://127.0.0.1:9090/internal/git-tools/run" + def test_default_port(self) -> None: + tools = _build_git_custom_tools("/tmp/wt") + assert tools[0]["http"]["url"] == "http://127.0.0.1:5000/internal/git-tools/run" def test_worktree_path_in_body(self) -> None: - with patch("webhook_server.libs.handlers.runner_handler.Config") as mock_config: - mock_config.return_value.root_data = {"port": 5000} - tools = _build_git_custom_tools("/data/worktrees/abc123") + tools = _build_git_custom_tools("/data/worktrees/abc123") for tool in tools: assert tool["http"]["body_template"]["cwd"] == "/data/worktrees/abc123" diff --git a/webhook_server/web/git_tools.py b/webhook_server/web/git_tools.py index d02a820fb..d1676ca0c 100644 --- a/webhook_server/web/git_tools.py +++ b/webhook_server/web/git_tools.py @@ -9,10 +9,14 @@ from __future__ import annotations import asyncio +import ipaddress import shlex from fastapi import APIRouter, HTTPException from pydantic import BaseModel +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import JSONResponse, Response ALLOWED_GIT_COMMANDS = frozenset({"diff", "log", "show", "status", "rev-parse"}) BLOCKED_FLAGS = frozenset({"--no-index", "--output", "--raw"}) @@ -20,6 +24,25 @@ router = APIRouter(prefix="/internal/git-tools", tags=["internal"]) +class LocalhostOnlyMiddleware(BaseHTTPMiddleware): + """Reject non-localhost requests to /internal/ endpoints.""" + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + if request.url.path.startswith("/internal/"): + if not request.client: + return JSONResponse(status_code=403, content={"detail": "Internal endpoints are localhost-only"}) + client_host = request.client.host + try: + ip = ipaddress.ip_address(client_host) + if not ip.is_loopback: + return JSONResponse(status_code=403, content={"detail": "Internal endpoints are localhost-only"}) + except ValueError: + # Non-IP host — deny unless it's the test client + if client_host != "testclient": + return JSONResponse(status_code=403, content={"detail": "Internal endpoints are localhost-only"}) + return await call_next(request) + + class GitCommandRequest(BaseModel): cwd: str args: str @@ -45,7 +68,7 @@ async def run_git_command(request: GitCommandRequest) -> GitCommandResponse: ) for part in parts[1:]: - if part in BLOCKED_FLAGS: + if any(part == flag or part.startswith(f"{flag}=") for flag in BLOCKED_FLAGS): raise HTTPException( status_code=403, detail=f"Git flag '{part}' not allowed for security reasons", @@ -63,8 +86,11 @@ async def run_git_command(request: GitCommandRequest) -> GitCommandResponse: stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) stdout_text = stdout.decode(errors="replace") stderr_text = stderr.decode(errors="replace") + # git diff exit code 1 means "differences found" (not an error) + is_diff_command = parts[0] == "diff" + success = proc.returncode == 0 or (is_diff_command and proc.returncode == 1 and bool(stdout_text.strip())) output = stdout_text if stdout_text.strip() else stderr_text - return GitCommandResponse(success=proc.returncode == 0 or bool(stdout_text.strip()), output=output[:50000]) + return GitCommandResponse(success=success, output=output[:50000]) except TimeoutError: if proc: proc.kill() From 9d5708676e7000bf9bb4c579171331783ba5beb9 Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Mon, 22 Jun 2026 21:42:41 +0300 Subject: [PATCH 07/13] chore: trigger Qodo re-evaluation of resolved findings --- webhook_server/libs/handlers/runner_handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/webhook_server/libs/handlers/runner_handler.py b/webhook_server/libs/handlers/runner_handler.py index b5e26a8ef..15d6736c9 100644 --- a/webhook_server/libs/handlers/runner_handler.py +++ b/webhook_server/libs/handlers/runner_handler.py @@ -93,6 +93,7 @@ def _build_git_custom_tools(worktree_path: str, server_port: int = 5000) -> list "method": "POST", "url": base_url, "body_template": {"cwd": worktree_path, "args": f"{cmd_name} {{args}}"}, + "timeoutMs": 120000, # 120s — allow for event loop contention under heavy CI load }, }) From 1eefb4e7ae386bd5146d05f6d756b468a55003c4 Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Tue, 23 Jun 2026 09:15:33 +0300 Subject: [PATCH 08/13] refactor: move git-tools to standalone aiohttp server on port 5001 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace FastAPI router with standalone aiohttp server in daemon thread - Own event loop — no contention with main webhook server during CI checks - Binds to 127.0.0.1:5001 (localhost-only, no middleware needed) - Custom tools now point to port 5001 - Add aiohttp dependency - 23 tests rewritten for aiohttp test client --- CLAUDE.md | 2 + Dockerfile | 1 + entrypoint.py | 5 + pyproject.toml | 1 + uv.lock | 223 ++++++++++++++++++ webhook_server/app.py | 5 - .../libs/handlers/runner_handler.py | 19 +- webhook_server/tests/test_git_tools.py | 213 +++++++++++------ webhook_server/web/git_tools.py | 113 +++++---- 9 files changed, 441 insertions(+), 141 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 43228819b..c31051004 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -708,6 +708,8 @@ AI-powered enhancements controlled by `ai-features` config (global or per-repo). **`SIDECAR_PORT`** env var controls the sidecar listen port (default: `9100`). +**Git-tools server:** Standalone aiohttp server on port `5001` (`webhook_server/web/git_tools.py`). Runs in a dedicated thread with its own event loop, isolated from the main webhook server. Provides HTTP-backed custom tools (`git_diff`, `git_log`, `git_show`, `git_status`) for the pi-sidecar during AI sessions. Localhost-only (`127.0.0.1`), restricted to read-only git subcommands. Started by `entrypoint.py` before uvicorn. + **Dual healthcheck:** Dockerfile `HEALTHCHECK` verifies both the webhook server (`:5000/webhook_server/healthcheck`) and the sidecar (`:${SIDECAR_PORT}/health`). Container is unhealthy if either fails. **Docker build:** Multi-stage — `sidecar-builder` stage (node:22-slim) runs `npm ci`, `npx tsc`, `npm prune --omit=dev`. Final stage copies only `dist/`, `node_modules/` (production), and `package.json`. diff --git a/Dockerfile b/Dockerfile index 2324ab5c7..3c8042912 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ RUN npm prune --omit=dev FROM quay.io/podman/stable:v5 EXPOSE 5000 +EXPOSE 5001 EXPOSE 9100 ENV USERNAME="podman" diff --git a/entrypoint.py b/entrypoint.py index 598c2ac60..ecb766680 100644 --- a/entrypoint.py +++ b/entrypoint.py @@ -9,6 +9,7 @@ from webhook_server.libs.config import Config from webhook_server.utils.github_repository_and_webhook_settings import repository_and_webhook_settings +from webhook_server.web.git_tools import GIT_TOOLS_PORT, start_git_tools_server _config = Config() _root_config = _config.root_data @@ -64,4 +65,8 @@ def run_podman_cleanup() -> None: if not _dev_mode: uvicorn_kwargs["workers"] = int(_max_workers) + # Start git-tools server on separate event loop (avoids contention with CI checks) + start_git_tools_server() + print(f"\u2705 Git-tools server started on 127.0.0.1:{GIT_TOOLS_PORT}") + uvicorn.run("webhook_server.app:FASTAPI_APP", **uvicorn_kwargs) diff --git a/pyproject.toml b/pyproject.toml index 574ff09ff..3aaac4118 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,7 @@ dependencies = [ "psutil>=7.0.0", "fastapi-mcp>=0.4.0", "pi-sidecar-client>=1.1.0", + "aiohttp>=3.9.0", ] [[project.authors]] diff --git a/uv.lock b/uv.lock index 3fb489628..c9d5bfec4 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, ] +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +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" @@ -363,12 +424,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/83/6bf02ff9e3ca1d24765050e3b51dceae9bb69909cc5385623cf6f3fd7c23/fastapi_mcp-0.4.0-py3-none-any.whl", hash = "sha256:d4a3fe7966af24d44e4b412720561c95eb12bed999a4443a88221834b3b15aec", size = 25085, upload-time = "2025-07-28T12:11:04.472Z" }, ] +[[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/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/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 = "github-webhook-server" version = "4.0.1" source = { editable = "." } dependencies = [ { name = "aiofiles" }, + { name = "aiohttp" }, { name = "asyncstdlib" }, { name = "build" }, { name = "colorama" }, @@ -420,6 +523,7 @@ tests = [ [package.metadata] requires-dist = [ { name = "aiofiles", specifier = ">=24.1.0" }, + { name = "aiohttp", specifier = ">=3.9.0" }, { name = "asyncstdlib", specifier = ">=3.13.1" }, { name = "build", specifier = ">=1.2.2.post1" }, { name = "colorama", specifier = ">=0.4.6" }, @@ -697,6 +801,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + [[package]] name = "netaddr" version = "1.3.0" @@ -782,6 +931,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] +[[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/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/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 = "psutil" version = "7.2.2" @@ -1539,3 +1731,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +] diff --git a/webhook_server/app.py b/webhook_server/app.py index 41ffe22b4..f6b3177f2 100644 --- a/webhook_server/app.py +++ b/webhook_server/app.py @@ -52,8 +52,6 @@ prepare_log_prefix, ) from webhook_server.utils.structured_logger import write_webhook_log -from webhook_server.web.git_tools import LocalhostOnlyMiddleware -from webhook_server.web.git_tools import router as git_tools_router from webhook_server.web.log_viewer import LogViewerController # Constants @@ -300,9 +298,6 @@ async def run_manager() -> None: FASTAPI_APP: FastAPI = FastAPI(title="webhook-server", lifespan=lifespan) -FASTAPI_APP.add_middleware(LocalhostOnlyMiddleware) -FASTAPI_APP.include_router(git_tools_router) - # Mount static files static_files_path = os.path.join(os.path.dirname(__file__), "web", "static") FASTAPI_APP.mount("/static", StaticFiles(directory=static_files_path), name="static") diff --git a/webhook_server/libs/handlers/runner_handler.py b/webhook_server/libs/handlers/runner_handler.py index 15d6736c9..8c15a0fcf 100644 --- a/webhook_server/libs/handlers/runner_handler.py +++ b/webhook_server/libs/handlers/runner_handler.py @@ -38,6 +38,7 @@ from webhook_server.utils.github_retry import github_api_call from webhook_server.utils.helpers import _redact_secrets, run_command from webhook_server.utils.notification_utils import send_slack_message +from webhook_server.web.git_tools import GIT_TOOLS_PORT if TYPE_CHECKING: from webhook_server.libs.github_api import GithubWebhook @@ -61,7 +62,7 @@ class CheckConfig: use_cwd: bool = False -def _build_git_custom_tools(worktree_path: str, server_port: int = 5000) -> list[dict[str, Any]]: +def _build_git_custom_tools(worktree_path: str, server_port: int = GIT_TOOLS_PORT) -> list[dict[str, Any]]: """Build HTTP-backed custom tools for read-only git operations. These tools are executed by the pi-sidecar via HTTP calls to the @@ -126,18 +127,6 @@ def __init__(self, github_webhook: "GithubWebhook", owners_file_handler: OwnersF github_webhook=self.github_webhook, owners_file_handler=self.owners_file_handler ) - @property - def _server_port(self) -> int: - """Webhook server port for internal git-tools endpoint. - - Reads from config on each access. Falls back to 5000 if the config - value is not a valid integer (e.g., in test environments with mocks). - """ - try: - return int(self.github_webhook.config.root_data.get("port", 5000)) - except (TypeError, ValueError): - return 5000 - @contextlib.asynccontextmanager async def _checkout_worktree( self, @@ -992,7 +981,7 @@ async def _get_ai_title_suggestion( cwd=worktree_path, timeout_minutes=timeout_minutes, tools=[], # No builtin tools - custom_tools=_build_git_custom_tools(worktree_path, server_port=self._server_port), + custom_tools=_build_git_custom_tools(worktree_path), ) if ai_result.success: @@ -1336,7 +1325,7 @@ async def _resolve_cherry_pick_with_ai( timeout_minutes=timeout_minutes, system_prompt=system_prompt, tools=["read", "edit", "write", "grep", "find", "ls"], - custom_tools=_build_git_custom_tools(worktree_path, server_port=self._server_port), + custom_tools=_build_git_custom_tools(worktree_path), ) if not ai_call_result.success: diff --git a/webhook_server/tests/test_git_tools.py b/webhook_server/tests/test_git_tools.py index 90a36f58c..05e08e9d1 100644 --- a/webhook_server/tests/test_git_tools.py +++ b/webhook_server/tests/test_git_tools.py @@ -1,163 +1,190 @@ -"""Tests for webhook_server.web.git_tools internal endpoints.""" +"""Tests for webhook_server.web.git_tools standalone server.""" from __future__ import annotations from unittest.mock import AsyncMock, Mock, patch import pytest -from fastapi.testclient import TestClient +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer -from webhook_server.app import FASTAPI_APP from webhook_server.libs.handlers.runner_handler import _build_git_custom_tools +from webhook_server.web.git_tools import run_git_command MOCK_TARGET = "webhook_server.web.git_tools.asyncio.create_subprocess_exec" +def _create_app() -> web.Application: + app = web.Application() + app.router.add_post("/internal/git-tools/run", run_git_command) + return app + + +@pytest.fixture +async def client() -> TestClient: + app = _create_app() + server = TestServer(app) + _client = TestClient(server) + await _client.start_server() + yield _client + await _client.close() + + class TestGitToolsEndpoint: """Test suite for /internal/git-tools/run endpoint.""" - @pytest.fixture - def client(self) -> TestClient: - return TestClient(FASTAPI_APP) - - def test_allowed_command_diff(self, client: TestClient) -> None: + @pytest.mark.asyncio + async def test_allowed_command_diff(self, client: TestClient) -> None: with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"file.py | 2 +-\n", b"") process.returncode = 0 mock_proc.return_value = process - resp = client.post( + resp = await client.post( "/internal/git-tools/run", json={"cwd": "/tmp/test-repo", "args": "diff origin/main --stat"}, ) - assert resp.status_code == 200 - data = resp.json() + assert resp.status == 200 + data = await resp.json() assert data["success"] is True assert "file.py" in data["output"] - def test_allowed_command_log(self, client: TestClient) -> None: + @pytest.mark.asyncio + async def test_allowed_command_log(self, client: TestClient) -> None: with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"abc1234 feat: add feature\n", b"") process.returncode = 0 mock_proc.return_value = process - resp = client.post( + resp = await client.post( "/internal/git-tools/run", json={"cwd": "/tmp/test-repo", "args": "log origin/main..HEAD --oneline"}, ) - assert resp.status_code == 200 - data = resp.json() + assert resp.status == 200 + data = await resp.json() assert data["success"] is True assert "feat: add feature" in data["output"] - def test_allowed_command_show(self, client: TestClient) -> None: + @pytest.mark.asyncio + async def test_allowed_command_show(self, client: TestClient) -> None: with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"commit abc1234\nAuthor: test\n", b"") process.returncode = 0 mock_proc.return_value = process - resp = client.post( + resp = await client.post( "/internal/git-tools/run", json={"cwd": "/tmp/test-repo", "args": "show HEAD"}, ) - assert resp.status_code == 200 - data = resp.json() + assert resp.status == 200 + data = await resp.json() assert data["success"] is True - def test_allowed_command_status(self, client: TestClient) -> None: + @pytest.mark.asyncio + async def test_allowed_command_status(self, client: TestClient) -> None: with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"On branch main\nnothing to commit\n", b"") process.returncode = 0 mock_proc.return_value = process - resp = client.post( + resp = await client.post( "/internal/git-tools/run", json={"cwd": "/tmp/test-repo", "args": "status"}, ) - assert resp.status_code == 200 - data = resp.json() + assert resp.status == 200 + data = await resp.json() assert data["success"] is True - def test_allowed_command_rev_parse(self, client: TestClient) -> None: + @pytest.mark.asyncio + async def test_allowed_command_rev_parse(self, client: TestClient) -> None: with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"abc1234def5678\n", b"") process.returncode = 0 mock_proc.return_value = process - resp = client.post( + resp = await client.post( "/internal/git-tools/run", json={"cwd": "/tmp/test-repo", "args": "rev-parse HEAD"}, ) - assert resp.status_code == 200 - data = resp.json() + assert resp.status == 200 + data = await resp.json() assert data["success"] is True - def test_blocked_command_push(self, client: TestClient) -> None: - resp = client.post( + @pytest.mark.asyncio + async def test_blocked_command_push(self, client: TestClient) -> None: + resp = await client.post( "/internal/git-tools/run", json={"cwd": "/tmp/test-repo", "args": "push origin main"}, ) - assert resp.status_code == 403 - assert "push" in resp.json()["detail"] - assert "not allowed" in resp.json()["detail"] - - def test_blocked_command_checkout(self, client: TestClient) -> None: - resp = client.post( + assert resp.status == 403 + data = await resp.json() + assert "push" in data["detail"] + assert "not allowed" in data["detail"] + + @pytest.mark.asyncio + async def test_blocked_command_checkout(self, client: TestClient) -> None: + resp = await client.post( "/internal/git-tools/run", json={"cwd": "/tmp/test-repo", "args": "checkout main"}, ) - assert resp.status_code == 403 + assert resp.status == 403 - def test_blocked_command_reset(self, client: TestClient) -> None: - resp = client.post( + @pytest.mark.asyncio + async def test_blocked_command_reset(self, client: TestClient) -> None: + resp = await client.post( "/internal/git-tools/run", json={"cwd": "/tmp/test-repo", "args": "reset --hard HEAD~1"}, ) - assert resp.status_code == 403 + assert resp.status == 403 - def test_blocked_command_rm(self, client: TestClient) -> None: - resp = client.post( + @pytest.mark.asyncio + async def test_blocked_command_rm(self, client: TestClient) -> None: + resp = await client.post( "/internal/git-tools/run", json={"cwd": "/tmp/test-repo", "args": "rm file.py"}, ) - assert resp.status_code == 403 + assert resp.status == 403 - def test_empty_args(self, client: TestClient) -> None: - resp = client.post( + @pytest.mark.asyncio + async def test_empty_args(self, client: TestClient) -> None: + resp = await client.post( "/internal/git-tools/run", json={"cwd": "/tmp/test-repo", "args": ""}, ) - assert resp.status_code == 400 - assert "Empty" in resp.json()["detail"] + assert resp.status == 400 + data = await resp.json() + assert "Missing" in data["detail"] - def test_git_command_failure_returns_stderr(self, client: TestClient) -> None: + @pytest.mark.asyncio + async def test_git_command_failure_returns_stderr(self, client: TestClient) -> None: with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"", b"fatal: not a git repository\n") process.returncode = 128 mock_proc.return_value = process - resp = client.post( + resp = await client.post( "/internal/git-tools/run", json={"cwd": "/tmp/not-a-repo", "args": "diff HEAD"}, ) - assert resp.status_code == 200 - data = resp.json() + assert resp.status == 200 + data = await resp.json() assert data["success"] is False assert "not a git repository" in data["output"] - def test_timeout_kills_process(self, client: TestClient) -> None: + @pytest.mark.asyncio + async def test_timeout_kills_process(self, client: TestClient) -> None: with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.side_effect = TimeoutError() @@ -165,18 +192,19 @@ def test_timeout_kills_process(self, client: TestClient) -> None: process.wait = AsyncMock() mock_proc.return_value = process - resp = client.post( + resp = await client.post( "/internal/git-tools/run", json={"cwd": "/tmp/test-repo", "args": "diff HEAD"}, ) - assert resp.status_code == 200 - data = resp.json() + assert resp.status == 200 + data = await resp.json() assert data["success"] is False assert "timed out" in data["output"] process.kill.assert_called_once() - def test_output_capped_at_50k(self, client: TestClient) -> None: + @pytest.mark.asyncio + async def test_output_capped_at_50k(self, client: TestClient) -> None: with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() large_output = b"x" * 100_000 @@ -184,48 +212,94 @@ def test_output_capped_at_50k(self, client: TestClient) -> None: process.returncode = 0 mock_proc.return_value = process - resp = client.post( + resp = await client.post( "/internal/git-tools/run", json={"cwd": "/tmp/test-repo", "args": "diff HEAD"}, ) - assert resp.status_code == 200 - data = resp.json() + assert resp.status == 200 + data = await resp.json() assert data["success"] is True assert len(data["output"]) == 50_000 - def test_oserror_exception(self, client: TestClient) -> None: + @pytest.mark.asyncio + async def test_oserror_exception(self, client: TestClient) -> None: with patch(MOCK_TARGET) as mock_proc: mock_proc.side_effect = OSError("No such file or directory") - resp = client.post( + resp = await client.post( "/internal/git-tools/run", json={"cwd": "/tmp/test-repo", "args": "diff HEAD"}, ) - assert resp.status_code == 200 - data = resp.json() + assert resp.status == 200 + data = await resp.json() assert data["success"] is False assert "No such file" in data["output"] - def test_cwd_passed_as_exec_arg(self, client: TestClient) -> None: - """Verify cwd with spaces is passed as a separate exec argument (no shell quoting needed).""" + @pytest.mark.asyncio + async def test_cwd_passed_as_exec_arg(self, client: TestClient) -> None: + """Verify cwd with spaces is passed as a separate exec argument.""" with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"ok\n", b"") process.returncode = 0 mock_proc.return_value = process - resp = client.post( + resp = await client.post( "/internal/git-tools/run", json={"cwd": "/tmp/path with spaces/repo", "args": "status"}, ) - assert resp.status_code == 200 - # Verify cwd is passed as a separate argument (no shell quoting) + assert resp.status == 200 call_args = mock_proc.call_args[0] assert call_args == ("git", "-C", "/tmp/path with spaces/repo", "status") + @pytest.mark.asyncio + async def test_blocked_flag_with_equals(self, client: TestClient) -> None: + """Verify --output=/tmp/x is blocked.""" + resp = await client.post( + "/internal/git-tools/run", + json={"cwd": "/tmp/test-repo", "args": "diff --output=/tmp/x HEAD"}, + ) + assert resp.status == 403 + + @pytest.mark.asyncio + async def test_diff_exit_code_1_is_success(self, client: TestClient) -> None: + """git diff returns exit code 1 when differences exist — should be success.""" + with patch(MOCK_TARGET) as mock_proc: + process = AsyncMock() + process.communicate.return_value = (b"file.py | 2 +-\n", b"") + process.returncode = 1 + mock_proc.return_value = process + + resp = await client.post( + "/internal/git-tools/run", + json={"cwd": "/tmp/test-repo", "args": "diff HEAD"}, + ) + + assert resp.status == 200 + data = await resp.json() + assert data["success"] is True + + @pytest.mark.asyncio + async def test_log_exit_code_1_is_failure(self, client: TestClient) -> None: + """git log exit code 1 is a real error — should be failure.""" + with patch(MOCK_TARGET) as mock_proc: + process = AsyncMock() + process.communicate.return_value = (b"", b"fatal: bad default revision\n") + process.returncode = 1 + mock_proc.return_value = process + + resp = await client.post( + "/internal/git-tools/run", + json={"cwd": "/tmp/test-repo", "args": "log HEAD"}, + ) + + assert resp.status == 200 + data = await resp.json() + assert data["success"] is False + class TestBuildGitCustomTools: """Test suite for _build_git_custom_tools helper.""" @@ -245,9 +319,10 @@ def test_tool_structure(self) -> None: assert "args" in tool["parameters"]["properties"] assert tool["parameters"]["required"] == ["args"] assert tool["http"]["method"] == "POST" - assert tool["http"]["url"] == "http://127.0.0.1:5000/internal/git-tools/run" + assert tool["http"]["url"] == "http://127.0.0.1:5001/internal/git-tools/run" assert tool["http"]["body_template"]["cwd"] == "/tmp/my-worktree" assert "diff" in tool["http"]["body_template"]["args"] + assert tool["http"]["timeoutMs"] == 120000 def test_custom_server_port(self) -> None: tools = _build_git_custom_tools("/tmp/wt", server_port=8080) @@ -255,7 +330,7 @@ def test_custom_server_port(self) -> None: def test_default_port(self) -> None: tools = _build_git_custom_tools("/tmp/wt") - assert tools[0]["http"]["url"] == "http://127.0.0.1:5000/internal/git-tools/run" + assert tools[0]["http"]["url"] == "http://127.0.0.1:5001/internal/git-tools/run" def test_worktree_path_in_body(self) -> None: tools = _build_git_custom_tools("/data/worktrees/abc123") diff --git a/webhook_server/web/git_tools.py b/webhook_server/web/git_tools.py index d1676ca0c..b37cc5e93 100644 --- a/webhook_server/web/git_tools.py +++ b/webhook_server/web/git_tools.py @@ -1,81 +1,64 @@ -"""Internal HTTP endpoints for AI custom git tools. +"""Standalone async HTTP server for AI custom git tools. -These endpoints are called by the pi-sidecar as HTTP-backed custom tools -during AI sessions. They execute read-only git commands in a specified directory. +Runs on a separate port (default: 5001) with its own event loop thread, +so git command execution never competes with the main webhook server's +event loop during heavy CI processing. -SECURITY: Bound to 127.0.0.1 only. Restricted to read-only git subcommands. +SECURITY: Localhost-only. Restricted to read-only git subcommands. """ from __future__ import annotations import asyncio -import ipaddress import shlex +import threading +from typing import Any -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel -from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint -from starlette.requests import Request -from starlette.responses import JSONResponse, Response +from aiohttp import web ALLOWED_GIT_COMMANDS = frozenset({"diff", "log", "show", "status", "rev-parse"}) BLOCKED_FLAGS = frozenset({"--no-index", "--output", "--raw"}) -router = APIRouter(prefix="/internal/git-tools", tags=["internal"]) +GIT_TOOLS_PORT = 5001 -class LocalhostOnlyMiddleware(BaseHTTPMiddleware): - """Reject non-localhost requests to /internal/ endpoints.""" - - async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: - if request.url.path.startswith("/internal/"): - if not request.client: - return JSONResponse(status_code=403, content={"detail": "Internal endpoints are localhost-only"}) - client_host = request.client.host - try: - ip = ipaddress.ip_address(client_host) - if not ip.is_loopback: - return JSONResponse(status_code=403, content={"detail": "Internal endpoints are localhost-only"}) - except ValueError: - # Non-IP host — deny unless it's the test client - if client_host != "testclient": - return JSONResponse(status_code=403, content={"detail": "Internal endpoints are localhost-only"}) - return await call_next(request) - - -class GitCommandRequest(BaseModel): - cwd: str - args: str +async def run_git_command(request: web.Request) -> web.Response: + """Execute a read-only git command in the specified directory.""" + try: + data: dict[str, Any] = await request.json() + except Exception: + return web.json_response({"detail": "Invalid JSON"}, status=400) + cwd = data.get("cwd", "") + args = data.get("args", "") -class GitCommandResponse(BaseModel): - success: bool - output: str + if not cwd or not args: + return web.json_response({"detail": "Missing cwd or args"}, status=400) + try: + parts = shlex.split(args) + except ValueError as ex: + return web.json_response({"success": False, "output": str(ex)}) -@router.post("/run") -async def run_git_command(request: GitCommandRequest) -> GitCommandResponse: - """Execute a read-only git command in the specified directory.""" - parts = shlex.split(request.args) if not parts: - raise HTTPException(status_code=400, detail="Empty git command") + return web.json_response({"detail": "Empty git command"}, status=400) subcommand = parts[0] if subcommand not in ALLOWED_GIT_COMMANDS: - raise HTTPException( - status_code=403, - detail=f"Git subcommand '{subcommand}' not allowed. Allowed: {', '.join(sorted(ALLOWED_GIT_COMMANDS))}", + allowed = ", ".join(sorted(ALLOWED_GIT_COMMANDS)) + return web.json_response( + {"detail": f"Git subcommand '{subcommand}' not allowed. Allowed: {allowed}"}, + status=403, ) for part in parts[1:]: if any(part == flag or part.startswith(f"{flag}=") for flag in BLOCKED_FLAGS): - raise HTTPException( - status_code=403, - detail=f"Git flag '{part}' not allowed for security reasons", + return web.json_response( + {"detail": f"Git flag '{part}' not allowed for security reasons"}, + status=403, ) - # Build argument list — no shell interpolation - cmd_args = ["git", "-C", request.cwd, *parts] + cmd_args = ["git", "-C", cwd, *parts] proc: asyncio.subprocess.Process | None = None try: proc = await asyncio.create_subprocess_exec( @@ -90,11 +73,37 @@ async def run_git_command(request: GitCommandRequest) -> GitCommandResponse: is_diff_command = parts[0] == "diff" success = proc.returncode == 0 or (is_diff_command and proc.returncode == 1 and bool(stdout_text.strip())) output = stdout_text if stdout_text.strip() else stderr_text - return GitCommandResponse(success=success, output=output[:50000]) + return web.json_response({"success": success, "output": output[:50000]}) except TimeoutError: if proc: proc.kill() await proc.wait() - return GitCommandResponse(success=False, output="Command timed out after 30s") + return web.json_response({"success": False, "output": "Command timed out after 30s"}) except OSError as ex: - return GitCommandResponse(success=False, output=str(ex)) + return web.json_response({"success": False, "output": str(ex)}) + + +def _run_server(port: int) -> None: + """Run the git-tools server in a dedicated thread with its own event loop.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + app = web.Application() + app.router.add_post("/internal/git-tools/run", run_git_command) + + runner = web.AppRunner(app) + loop.run_until_complete(runner.setup()) + site = web.TCPSite(runner, "127.0.0.1", port) + loop.run_until_complete(site.start()) + loop.run_forever() + + +def start_git_tools_server(port: int = GIT_TOOLS_PORT) -> threading.Thread: + """Start the git-tools server in a background thread. + + Returns the thread handle. The server runs on 127.0.0.1:{port} + with its own event loop, isolated from the main webhook server. + """ + thread = threading.Thread(target=_run_server, args=(port,), daemon=True, name="git-tools-server") + thread.start() + return thread From d6a10a7c9a133487ed9c212f8fc9c6a36b1b5594 Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Tue, 23 Jun 2026 09:53:45 +0300 Subject: [PATCH 09/13] refactor: generic tool server with registry pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace git-specific server with generic tool registry - ToolDef dataclass: command_prefix, timeout, blocked_flags, success_exit_codes - Callers select which tools AI sees per session - Easy to add new tools — one registry entry --- CLAUDE.md | 2 +- entrypoint.py | 8 +- .../libs/handlers/runner_handler.py | 46 ++-- ...{test_git_tools.py => test_tool_server.py} | 216 ++++++++++-------- webhook_server/web/git_tools.py | 109 --------- webhook_server/web/tool_server.py | 160 +++++++++++++ 6 files changed, 309 insertions(+), 232 deletions(-) rename webhook_server/tests/{test_git_tools.py => test_tool_server.py} (59%) delete mode 100644 webhook_server/web/git_tools.py create mode 100644 webhook_server/web/tool_server.py diff --git a/CLAUDE.md b/CLAUDE.md index c31051004..b16979a0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -708,7 +708,7 @@ AI-powered enhancements controlled by `ai-features` config (global or per-repo). **`SIDECAR_PORT`** env var controls the sidecar listen port (default: `9100`). -**Git-tools server:** Standalone aiohttp server on port `5001` (`webhook_server/web/git_tools.py`). Runs in a dedicated thread with its own event loop, isolated from the main webhook server. Provides HTTP-backed custom tools (`git_diff`, `git_log`, `git_show`, `git_status`) for the pi-sidecar during AI sessions. Localhost-only (`127.0.0.1`), restricted to read-only git subcommands. Started by `entrypoint.py` before uvicorn. +**Tool server:** Standalone aiohttp server on port `5001` (`webhook_server/web/tool_server.py`). Runs in a dedicated thread with its own event loop, isolated from the main webhook server. Uses a `TOOL_REGISTRY` pattern — adding a new tool is one `ToolDef` entry. Callers select which tools the AI sees per session via `_build_custom_tools()`. Currently registers `git_diff`, `git_log`, `git_show`, `git_status`. Localhost-only (`127.0.0.1`). Started by `entrypoint.py` before uvicorn. **Dual healthcheck:** Dockerfile `HEALTHCHECK` verifies both the webhook server (`:5000/webhook_server/healthcheck`) and the sidecar (`:${SIDECAR_PORT}/health`). Container is unhealthy if either fails. diff --git a/entrypoint.py b/entrypoint.py index ecb766680..7472dfea4 100644 --- a/entrypoint.py +++ b/entrypoint.py @@ -9,7 +9,7 @@ from webhook_server.libs.config import Config from webhook_server.utils.github_repository_and_webhook_settings import repository_and_webhook_settings -from webhook_server.web.git_tools import GIT_TOOLS_PORT, start_git_tools_server +from webhook_server.web.tool_server import TOOL_SERVER_PORT, start_tool_server _config = Config() _root_config = _config.root_data @@ -65,8 +65,8 @@ def run_podman_cleanup() -> None: if not _dev_mode: uvicorn_kwargs["workers"] = int(_max_workers) - # Start git-tools server on separate event loop (avoids contention with CI checks) - start_git_tools_server() - print(f"\u2705 Git-tools server started on 127.0.0.1:{GIT_TOOLS_PORT}") + # Start tool server on separate event loop (avoids contention with CI checks) + start_tool_server() + print(f"\u2705 Tool server started on 127.0.0.1:{TOOL_SERVER_PORT}") uvicorn.run("webhook_server.app:FASTAPI_APP", **uvicorn_kwargs) diff --git a/webhook_server/libs/handlers/runner_handler.py b/webhook_server/libs/handlers/runner_handler.py index 8c15a0fcf..74e839392 100644 --- a/webhook_server/libs/handlers/runner_handler.py +++ b/webhook_server/libs/handlers/runner_handler.py @@ -38,7 +38,7 @@ from webhook_server.utils.github_retry import github_api_call from webhook_server.utils.helpers import _redact_secrets, run_command from webhook_server.utils.notification_utils import send_slack_message -from webhook_server.web.git_tools import GIT_TOOLS_PORT +from webhook_server.web.tool_server import TOOL_REGISTRY, TOOL_SERVER_PORT if TYPE_CHECKING: from webhook_server.libs.github_api import GithubWebhook @@ -62,30 +62,35 @@ class CheckConfig: use_cwd: bool = False -def _build_git_custom_tools(worktree_path: str, server_port: int = GIT_TOOLS_PORT) -> list[dict[str, Any]]: - """Build HTTP-backed custom tools for read-only git operations. +def _build_custom_tools( + worktree_path: str, + tool_names: list[str], + server_port: int = TOOL_SERVER_PORT, +) -> list[dict[str, Any]]: + """Build HTTP-backed custom tool definitions for the pi-sidecar. - These tools are executed by the pi-sidecar via HTTP calls to the - webhook server's internal git-tools endpoint. + Selects tools from TOOL_REGISTRY by name. Each tool calls the + standalone tool server via HTTP. + + Args: + worktree_path: Working directory for the tools. + tool_names: Which tools to include (e.g., ["git_diff", "git_log"]). + server_port: Tool server port (default: TOOL_SERVER_PORT). """ - base_url = f"http://127.0.0.1:{server_port}/internal/git-tools/run" + base_url = f"http://127.0.0.1:{server_port}/tools/run" tools: list[dict[str, Any]] = [] - for cmd_name, description in [ - ("diff", "Run git diff to see code changes. Use '--stat' for summary, or file paths for specific files."), - ("log", "Run git log to see commit history. Use '--oneline' for compact output."), - ("show", "Run git show to inspect a commit or object."), - ("status", "Run git status to see working tree state."), - ]: + for name in tool_names: + tool_def = TOOL_REGISTRY[name] # KeyError = programming error tools.append({ - "name": f"git_{cmd_name}", - "description": description, + "name": name, + "description": tool_def.description, "parameters": { "type": "object", "properties": { "args": { "type": "string", - "description": f"Arguments to pass to git {cmd_name}", + "description": tool_def.args_description, } }, "required": ["args"], @@ -93,8 +98,11 @@ def _build_git_custom_tools(worktree_path: str, server_port: int = GIT_TOOLS_POR "http": { "method": "POST", "url": base_url, - "body_template": {"cwd": worktree_path, "args": f"{cmd_name} {{args}}"}, - "timeoutMs": 120000, # 120s — allow for event loop contention under heavy CI load + "body_template": { + "tool": name, + "cwd": worktree_path, + "args": "{args}", + }, }, }) @@ -981,7 +989,7 @@ async def _get_ai_title_suggestion( cwd=worktree_path, timeout_minutes=timeout_minutes, tools=[], # No builtin tools - custom_tools=_build_git_custom_tools(worktree_path), + custom_tools=_build_custom_tools(worktree_path, ["git_diff", "git_log"]), ) if ai_result.success: @@ -1325,7 +1333,7 @@ async def _resolve_cherry_pick_with_ai( timeout_minutes=timeout_minutes, system_prompt=system_prompt, tools=["read", "edit", "write", "grep", "find", "ls"], - custom_tools=_build_git_custom_tools(worktree_path), + custom_tools=_build_custom_tools(worktree_path, ["git_diff", "git_log", "git_show", "git_status"]), ) if not ai_call_result.success: diff --git a/webhook_server/tests/test_git_tools.py b/webhook_server/tests/test_tool_server.py similarity index 59% rename from webhook_server/tests/test_git_tools.py rename to webhook_server/tests/test_tool_server.py index 05e08e9d1..2f3b5582d 100644 --- a/webhook_server/tests/test_git_tools.py +++ b/webhook_server/tests/test_tool_server.py @@ -1,4 +1,4 @@ -"""Tests for webhook_server.web.git_tools standalone server.""" +"""Tests for webhook_server.web.tool_server standalone server.""" from __future__ import annotations @@ -8,15 +8,15 @@ from aiohttp import web from aiohttp.test_utils import TestClient, TestServer -from webhook_server.libs.handlers.runner_handler import _build_git_custom_tools -from webhook_server.web.git_tools import run_git_command +from webhook_server.libs.handlers.runner_handler import _build_custom_tools +from webhook_server.web.tool_server import handle_tool_request -MOCK_TARGET = "webhook_server.web.git_tools.asyncio.create_subprocess_exec" +MOCK_TARGET = "webhook_server.web.tool_server.asyncio.create_subprocess_exec" def _create_app() -> web.Application: app = web.Application() - app.router.add_post("/internal/git-tools/run", run_git_command) + app.router.add_post("/tools/run", handle_tool_request) return app @@ -30,11 +30,11 @@ async def client() -> TestClient: await _client.close() -class TestGitToolsEndpoint: - """Test suite for /internal/git-tools/run endpoint.""" +class TestToolServerEndpoint: + """Test suite for /tools/run endpoint.""" @pytest.mark.asyncio - async def test_allowed_command_diff(self, client: TestClient) -> None: + async def test_git_diff(self, client: TestClient) -> None: with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"file.py | 2 +-\n", b"") @@ -42,8 +42,8 @@ async def test_allowed_command_diff(self, client: TestClient) -> None: mock_proc.return_value = process resp = await client.post( - "/internal/git-tools/run", - json={"cwd": "/tmp/test-repo", "args": "diff origin/main --stat"}, + "/tools/run", + json={"tool": "git_diff", "cwd": "/tmp/test-repo", "args": "origin/main --stat"}, ) assert resp.status == 200 @@ -52,7 +52,7 @@ async def test_allowed_command_diff(self, client: TestClient) -> None: assert "file.py" in data["output"] @pytest.mark.asyncio - async def test_allowed_command_log(self, client: TestClient) -> None: + async def test_git_log(self, client: TestClient) -> None: with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"abc1234 feat: add feature\n", b"") @@ -60,8 +60,8 @@ async def test_allowed_command_log(self, client: TestClient) -> None: mock_proc.return_value = process resp = await client.post( - "/internal/git-tools/run", - json={"cwd": "/tmp/test-repo", "args": "log origin/main..HEAD --oneline"}, + "/tools/run", + json={"tool": "git_log", "cwd": "/tmp/test-repo", "args": "origin/main..HEAD --oneline"}, ) assert resp.status == 200 @@ -70,7 +70,7 @@ async def test_allowed_command_log(self, client: TestClient) -> None: assert "feat: add feature" in data["output"] @pytest.mark.asyncio - async def test_allowed_command_show(self, client: TestClient) -> None: + async def test_git_show(self, client: TestClient) -> None: with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"commit abc1234\nAuthor: test\n", b"") @@ -78,8 +78,8 @@ async def test_allowed_command_show(self, client: TestClient) -> None: mock_proc.return_value = process resp = await client.post( - "/internal/git-tools/run", - json={"cwd": "/tmp/test-repo", "args": "show HEAD"}, + "/tools/run", + json={"tool": "git_show", "cwd": "/tmp/test-repo", "args": "HEAD"}, ) assert resp.status == 200 @@ -87,7 +87,7 @@ async def test_allowed_command_show(self, client: TestClient) -> None: assert data["success"] is True @pytest.mark.asyncio - async def test_allowed_command_status(self, client: TestClient) -> None: + async def test_git_status(self, client: TestClient) -> None: with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"On branch main\nnothing to commit\n", b"") @@ -95,8 +95,8 @@ async def test_allowed_command_status(self, client: TestClient) -> None: mock_proc.return_value = process resp = await client.post( - "/internal/git-tools/run", - json={"cwd": "/tmp/test-repo", "args": "status"}, + "/tools/run", + json={"tool": "git_status", "cwd": "/tmp/test-repo", "args": ""}, ) assert resp.status == 200 @@ -104,69 +104,74 @@ async def test_allowed_command_status(self, client: TestClient) -> None: assert data["success"] is True @pytest.mark.asyncio - async def test_allowed_command_rev_parse(self, client: TestClient) -> None: - with patch(MOCK_TARGET) as mock_proc: - process = AsyncMock() - process.communicate.return_value = (b"abc1234def5678\n", b"") - process.returncode = 0 - mock_proc.return_value = process - - resp = await client.post( - "/internal/git-tools/run", - json={"cwd": "/tmp/test-repo", "args": "rev-parse HEAD"}, - ) - - assert resp.status == 200 + async def test_unknown_tool_returns_404(self, client: TestClient) -> None: + resp = await client.post( + "/tools/run", + json={"tool": "git_push", "cwd": "/tmp/test-repo", "args": "origin main"}, + ) + assert resp.status == 404 data = await resp.json() - assert data["success"] is True + assert "Unknown tool" in data["detail"] + assert "git_push" in data["detail"] @pytest.mark.asyncio - async def test_blocked_command_push(self, client: TestClient) -> None: + async def test_missing_tool_field(self, client: TestClient) -> None: resp = await client.post( - "/internal/git-tools/run", - json={"cwd": "/tmp/test-repo", "args": "push origin main"}, + "/tools/run", + json={"cwd": "/tmp/test-repo", "args": "status"}, ) - assert resp.status == 403 + assert resp.status == 400 data = await resp.json() - assert "push" in data["detail"] - assert "not allowed" in data["detail"] + assert "Missing 'tool'" in data["detail"] @pytest.mark.asyncio - async def test_blocked_command_checkout(self, client: TestClient) -> None: + async def test_missing_cwd_field(self, client: TestClient) -> None: resp = await client.post( - "/internal/git-tools/run", - json={"cwd": "/tmp/test-repo", "args": "checkout main"}, + "/tools/run", + json={"tool": "git_status", "args": ""}, ) - assert resp.status == 403 + assert resp.status == 400 + data = await resp.json() + assert "Missing 'cwd'" in data["detail"] @pytest.mark.asyncio - async def test_blocked_command_reset(self, client: TestClient) -> None: + async def test_blocked_flag_exact(self, client: TestClient) -> None: resp = await client.post( - "/internal/git-tools/run", - json={"cwd": "/tmp/test-repo", "args": "reset --hard HEAD~1"}, + "/tools/run", + json={"tool": "git_diff", "cwd": "/tmp/test-repo", "args": "--no-index /a /b"}, ) assert resp.status == 403 + data = await resp.json() + assert "not allowed" in data["detail"] @pytest.mark.asyncio - async def test_blocked_command_rm(self, client: TestClient) -> None: + async def test_blocked_flag_with_equals(self, client: TestClient) -> None: resp = await client.post( - "/internal/git-tools/run", - json={"cwd": "/tmp/test-repo", "args": "rm file.py"}, + "/tools/run", + json={"tool": "git_diff", "cwd": "/tmp/test-repo", "args": "--output=/tmp/x HEAD"}, ) assert resp.status == 403 @pytest.mark.asyncio - async def test_empty_args(self, client: TestClient) -> None: - resp = await client.post( - "/internal/git-tools/run", - json={"cwd": "/tmp/test-repo", "args": ""}, - ) - assert resp.status == 400 + async def test_unblocked_tool_allows_same_flag(self, client: TestClient) -> None: + """git_log has no blocked flags — --raw should be allowed.""" + with patch(MOCK_TARGET) as mock_proc: + process = AsyncMock() + process.communicate.return_value = (b"output\n", b"") + process.returncode = 0 + mock_proc.return_value = process + + resp = await client.post( + "/tools/run", + json={"tool": "git_log", "cwd": "/tmp/test-repo", "args": "--raw -5"}, + ) + + assert resp.status == 200 data = await resp.json() - assert "Missing" in data["detail"] + assert data["success"] is True @pytest.mark.asyncio - async def test_git_command_failure_returns_stderr(self, client: TestClient) -> None: + async def test_command_failure_returns_stderr(self, client: TestClient) -> None: with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"", b"fatal: not a git repository\n") @@ -174,8 +179,8 @@ async def test_git_command_failure_returns_stderr(self, client: TestClient) -> N mock_proc.return_value = process resp = await client.post( - "/internal/git-tools/run", - json={"cwd": "/tmp/not-a-repo", "args": "diff HEAD"}, + "/tools/run", + json={"tool": "git_diff", "cwd": "/tmp/not-a-repo", "args": "HEAD"}, ) assert resp.status == 200 @@ -193,8 +198,8 @@ async def test_timeout_kills_process(self, client: TestClient) -> None: mock_proc.return_value = process resp = await client.post( - "/internal/git-tools/run", - json={"cwd": "/tmp/test-repo", "args": "diff HEAD"}, + "/tools/run", + json={"tool": "git_diff", "cwd": "/tmp/test-repo", "args": "HEAD"}, ) assert resp.status == 200 @@ -213,8 +218,8 @@ async def test_output_capped_at_50k(self, client: TestClient) -> None: mock_proc.return_value = process resp = await client.post( - "/internal/git-tools/run", - json={"cwd": "/tmp/test-repo", "args": "diff HEAD"}, + "/tools/run", + json={"tool": "git_diff", "cwd": "/tmp/test-repo", "args": "HEAD"}, ) assert resp.status == 200 @@ -228,8 +233,8 @@ async def test_oserror_exception(self, client: TestClient) -> None: mock_proc.side_effect = OSError("No such file or directory") resp = await client.post( - "/internal/git-tools/run", - json={"cwd": "/tmp/test-repo", "args": "diff HEAD"}, + "/tools/run", + json={"tool": "git_diff", "cwd": "/tmp/test-repo", "args": "HEAD"}, ) assert resp.status == 200 @@ -238,8 +243,7 @@ async def test_oserror_exception(self, client: TestClient) -> None: assert "No such file" in data["output"] @pytest.mark.asyncio - async def test_cwd_passed_as_exec_arg(self, client: TestClient) -> None: - """Verify cwd with spaces is passed as a separate exec argument.""" + async def test_cwd_substituted_in_command(self, client: TestClient) -> None: with patch(MOCK_TARGET) as mock_proc: process = AsyncMock() process.communicate.return_value = (b"ok\n", b"") @@ -247,23 +251,14 @@ async def test_cwd_passed_as_exec_arg(self, client: TestClient) -> None: mock_proc.return_value = process resp = await client.post( - "/internal/git-tools/run", - json={"cwd": "/tmp/path with spaces/repo", "args": "status"}, + "/tools/run", + json={"tool": "git_status", "cwd": "/tmp/path with spaces/repo", "args": ""}, ) assert resp.status == 200 call_args = mock_proc.call_args[0] assert call_args == ("git", "-C", "/tmp/path with spaces/repo", "status") - @pytest.mark.asyncio - async def test_blocked_flag_with_equals(self, client: TestClient) -> None: - """Verify --output=/tmp/x is blocked.""" - resp = await client.post( - "/internal/git-tools/run", - json={"cwd": "/tmp/test-repo", "args": "diff --output=/tmp/x HEAD"}, - ) - assert resp.status == 403 - @pytest.mark.asyncio async def test_diff_exit_code_1_is_success(self, client: TestClient) -> None: """git diff returns exit code 1 when differences exist — should be success.""" @@ -274,8 +269,8 @@ async def test_diff_exit_code_1_is_success(self, client: TestClient) -> None: mock_proc.return_value = process resp = await client.post( - "/internal/git-tools/run", - json={"cwd": "/tmp/test-repo", "args": "diff HEAD"}, + "/tools/run", + json={"tool": "git_diff", "cwd": "/tmp/test-repo", "args": "HEAD"}, ) assert resp.status == 200 @@ -292,47 +287,70 @@ async def test_log_exit_code_1_is_failure(self, client: TestClient) -> None: mock_proc.return_value = process resp = await client.post( - "/internal/git-tools/run", - json={"cwd": "/tmp/test-repo", "args": "log HEAD"}, + "/tools/run", + json={"tool": "git_log", "cwd": "/tmp/test-repo", "args": "HEAD"}, ) assert resp.status == 200 data = await resp.json() assert data["success"] is False + @pytest.mark.asyncio + async def test_custom_timeout_override(self, client: TestClient) -> None: + """Caller can override the default timeout.""" + with patch(MOCK_TARGET) as mock_proc: + process = AsyncMock() + process.communicate.side_effect = TimeoutError() + process.kill = Mock() + process.wait = AsyncMock() + mock_proc.return_value = process + + resp = await client.post( + "/tools/run", + json={"tool": "git_diff", "cwd": "/tmp/test-repo", "args": "HEAD", "timeout": 5}, + ) + + assert resp.status == 200 + data = await resp.json() + assert "timed out after 5s" in data["output"] -class TestBuildGitCustomTools: - """Test suite for _build_git_custom_tools helper.""" - def test_builds_four_tools(self) -> None: - tools = _build_git_custom_tools("/tmp/wt") - assert len(tools) == 4 +class TestBuildCustomTools: + """Test suite for _build_custom_tools helper.""" + + def test_builds_selected_tools(self) -> None: + tools = _build_custom_tools("/tmp/wt", ["git_diff", "git_log"]) + assert len(tools) == 2 names = [t["name"] for t in tools] - assert names == ["git_diff", "git_log", "git_show", "git_status"] + assert names == ["git_diff", "git_log"] + + def test_builds_all_four_tools(self) -> None: + tools = _build_custom_tools("/tmp/wt", ["git_diff", "git_log", "git_show", "git_status"]) + assert len(tools) == 4 def test_tool_structure(self) -> None: - tools = _build_git_custom_tools("/tmp/my-worktree") - tool = tools[0] # git_diff + tools = _build_custom_tools("/tmp/my-worktree", ["git_diff"]) + tool = tools[0] assert tool["name"] == "git_diff" assert "description" in tool assert tool["parameters"]["type"] == "object" assert "args" in tool["parameters"]["properties"] assert tool["parameters"]["required"] == ["args"] assert tool["http"]["method"] == "POST" - assert tool["http"]["url"] == "http://127.0.0.1:5001/internal/git-tools/run" + assert tool["http"]["url"] == "http://127.0.0.1:5001/tools/run" + assert tool["http"]["body_template"]["tool"] == "git_diff" assert tool["http"]["body_template"]["cwd"] == "/tmp/my-worktree" - assert "diff" in tool["http"]["body_template"]["args"] - assert tool["http"]["timeoutMs"] == 120000 + assert tool["http"]["body_template"]["args"] == "{args}" def test_custom_server_port(self) -> None: - tools = _build_git_custom_tools("/tmp/wt", server_port=8080) - assert tools[0]["http"]["url"] == "http://127.0.0.1:8080/internal/git-tools/run" + tools = _build_custom_tools("/tmp/wt", ["git_diff"], server_port=8080) + assert tools[0]["http"]["url"] == "http://127.0.0.1:8080/tools/run" - def test_default_port(self) -> None: - tools = _build_git_custom_tools("/tmp/wt") - assert tools[0]["http"]["url"] == "http://127.0.0.1:5001/internal/git-tools/run" + def test_unknown_tool_raises_keyerror(self) -> None: + with pytest.raises(KeyError): + _build_custom_tools("/tmp/wt", ["nonexistent_tool"]) def test_worktree_path_in_body(self) -> None: - tools = _build_git_custom_tools("/data/worktrees/abc123") + tools = _build_custom_tools("/data/worktrees/abc123", ["git_log", "git_status"]) for tool in tools: assert tool["http"]["body_template"]["cwd"] == "/data/worktrees/abc123" diff --git a/webhook_server/web/git_tools.py b/webhook_server/web/git_tools.py deleted file mode 100644 index b37cc5e93..000000000 --- a/webhook_server/web/git_tools.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Standalone async HTTP server for AI custom git tools. - -Runs on a separate port (default: 5001) with its own event loop thread, -so git command execution never competes with the main webhook server's -event loop during heavy CI processing. - -SECURITY: Localhost-only. Restricted to read-only git subcommands. -""" - -from __future__ import annotations - -import asyncio -import shlex -import threading -from typing import Any - -from aiohttp import web - -ALLOWED_GIT_COMMANDS = frozenset({"diff", "log", "show", "status", "rev-parse"}) -BLOCKED_FLAGS = frozenset({"--no-index", "--output", "--raw"}) - -GIT_TOOLS_PORT = 5001 - - -async def run_git_command(request: web.Request) -> web.Response: - """Execute a read-only git command in the specified directory.""" - try: - data: dict[str, Any] = await request.json() - except Exception: - return web.json_response({"detail": "Invalid JSON"}, status=400) - - cwd = data.get("cwd", "") - args = data.get("args", "") - - if not cwd or not args: - return web.json_response({"detail": "Missing cwd or args"}, status=400) - - try: - parts = shlex.split(args) - except ValueError as ex: - return web.json_response({"success": False, "output": str(ex)}) - - if not parts: - return web.json_response({"detail": "Empty git command"}, status=400) - - subcommand = parts[0] - if subcommand not in ALLOWED_GIT_COMMANDS: - allowed = ", ".join(sorted(ALLOWED_GIT_COMMANDS)) - return web.json_response( - {"detail": f"Git subcommand '{subcommand}' not allowed. Allowed: {allowed}"}, - status=403, - ) - - for part in parts[1:]: - if any(part == flag or part.startswith(f"{flag}=") for flag in BLOCKED_FLAGS): - return web.json_response( - {"detail": f"Git flag '{part}' not allowed for security reasons"}, - status=403, - ) - - cmd_args = ["git", "-C", cwd, *parts] - proc: asyncio.subprocess.Process | None = None - try: - proc = await asyncio.create_subprocess_exec( - *cmd_args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) - stdout_text = stdout.decode(errors="replace") - stderr_text = stderr.decode(errors="replace") - # git diff exit code 1 means "differences found" (not an error) - is_diff_command = parts[0] == "diff" - success = proc.returncode == 0 or (is_diff_command and proc.returncode == 1 and bool(stdout_text.strip())) - output = stdout_text if stdout_text.strip() else stderr_text - return web.json_response({"success": success, "output": output[:50000]}) - except TimeoutError: - if proc: - proc.kill() - await proc.wait() - return web.json_response({"success": False, "output": "Command timed out after 30s"}) - except OSError as ex: - return web.json_response({"success": False, "output": str(ex)}) - - -def _run_server(port: int) -> None: - """Run the git-tools server in a dedicated thread with its own event loop.""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - app = web.Application() - app.router.add_post("/internal/git-tools/run", run_git_command) - - runner = web.AppRunner(app) - loop.run_until_complete(runner.setup()) - site = web.TCPSite(runner, "127.0.0.1", port) - loop.run_until_complete(site.start()) - loop.run_forever() - - -def start_git_tools_server(port: int = GIT_TOOLS_PORT) -> threading.Thread: - """Start the git-tools server in a background thread. - - Returns the thread handle. The server runs on 127.0.0.1:{port} - with its own event loop, isolated from the main webhook server. - """ - thread = threading.Thread(target=_run_server, args=(port,), daemon=True, name="git-tools-server") - thread.start() - return thread diff --git a/webhook_server/web/tool_server.py b/webhook_server/web/tool_server.py new file mode 100644 index 000000000..9bbc65517 --- /dev/null +++ b/webhook_server/web/tool_server.py @@ -0,0 +1,160 @@ +"""Standalone async tool server for AI custom tools. + +Runs on a separate port with its own event loop thread. +Tools are defined in a registry — adding a new tool is one entry. +Callers control which tools the AI sees per session. + +SECURITY: Binds to 127.0.0.1 only. Each tool defines its own +allowed arguments and blocked flags. +""" + +from __future__ import annotations + +import asyncio +import dataclasses +import shlex +import threading +from typing import Any + +from aiohttp import web + +TOOL_SERVER_PORT = 5001 + + +@dataclasses.dataclass(frozen=True) +class ToolDef: + """Definition of a registered tool.""" + + command_prefix: list[str] # Base command, e.g. ["git", "-C", "{cwd}"] + description: str + args_description: str + timeout: int = 30 # Default timeout in seconds + allowed_subcommands: frozenset[str] | None = None # If set, first arg must be in this set + blocked_flags: frozenset[str] = dataclasses.field(default_factory=frozenset) + success_exit_codes: frozenset[int] = dataclasses.field( + default_factory=lambda: frozenset({0}) + ) # Exit codes treated as success + + +# Tool registry — add new tools here, nothing else changes +TOOL_REGISTRY: dict[str, ToolDef] = { + "git_diff": ToolDef( + command_prefix=["git", "-C", "{cwd}", "diff"], + description="Run git diff to see code changes. Use '--stat' for summary, or file paths for specific files.", + args_description="Arguments for git diff (e.g., 'origin/main --stat', 'origin/main -- file.py')", + blocked_flags=frozenset({"--no-index", "--output", "--raw"}), + success_exit_codes=frozenset({0, 1}), # exit 1 = differences found (not an error) + ), + "git_log": ToolDef( + command_prefix=["git", "-C", "{cwd}", "log"], + description="Run git log to see commit history. Use '--oneline' for compact output.", + args_description="Arguments for git log (e.g., 'origin/main..HEAD --oneline', '-5')", + ), + "git_show": ToolDef( + command_prefix=["git", "-C", "{cwd}", "show"], + description="Run git show to inspect a commit or object.", + args_description="Arguments for git show (e.g., 'HEAD', 'abc123:file.py')", + ), + "git_status": ToolDef( + command_prefix=["git", "-C", "{cwd}", "status"], + description="Run git status to see working tree state.", + args_description="Arguments for git status (optional)", + ), +} + + +async def handle_tool_request(request: web.Request) -> web.Response: + """Execute a registered tool.""" + try: + data: dict[str, Any] = await request.json() + except Exception: + return web.json_response({"detail": "Invalid JSON"}, status=400) + + tool_name = data.get("tool", "") + cwd = data.get("cwd", "") + args = data.get("args", "") + timeout = data.get("timeout") # Caller can override default + + if not tool_name: + return web.json_response({"detail": "Missing 'tool' field"}, status=400) + + tool_def = TOOL_REGISTRY.get(tool_name) + if not tool_def: + available = ", ".join(sorted(TOOL_REGISTRY.keys())) + return web.json_response( + {"detail": f"Unknown tool '{tool_name}'. Available: {available}"}, + status=404, + ) + + if not cwd: + return web.json_response({"detail": "Missing 'cwd' field"}, status=400) + + # Parse and validate args + try: + parts = shlex.split(args) if args else [] + except ValueError as ex: + return web.json_response({"success": False, "output": str(ex)}) + + # Check allowed subcommands (if the tool defines them) + if tool_def.allowed_subcommands and parts: + if parts[0] not in tool_def.allowed_subcommands: + allowed = ", ".join(sorted(tool_def.allowed_subcommands)) + return web.json_response( + {"detail": f"Subcommand '{parts[0]}' not allowed. Allowed: {allowed}"}, + status=403, + ) + + # Check blocked flags + for part in parts: + if any(part == flag or part.startswith(f"{flag}=") for flag in tool_def.blocked_flags): + return web.json_response( + {"detail": f"Flag '{part}' not allowed for security reasons"}, + status=403, + ) + + # Build command — substitute {cwd} in prefix + cmd_args = [arg.replace("{cwd}", cwd) for arg in tool_def.command_prefix] + parts + + # Use caller timeout or tool default + effective_timeout = timeout if timeout is not None else tool_def.timeout + + proc: asyncio.subprocess.Process | None = None + try: + proc = await asyncio.create_subprocess_exec( + *cmd_args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=effective_timeout) + stdout_text = stdout.decode(errors="replace") + stderr_text = stderr.decode(errors="replace") + success = proc.returncode in tool_def.success_exit_codes + output = stdout_text if stdout_text.strip() else stderr_text + return web.json_response({"success": success, "output": output[:50000]}) + except TimeoutError: + if proc: + proc.kill() + await proc.wait() + return web.json_response({"success": False, "output": f"Command timed out after {effective_timeout}s"}) + except OSError as ex: + return web.json_response({"success": False, "output": str(ex)}) + + +def _run_server(port: int) -> None: + """Run in a dedicated thread with its own event loop.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + app = web.Application() + app.router.add_post("/tools/run", handle_tool_request) + runner = web.AppRunner(app) + loop.run_until_complete(runner.setup()) + site = web.TCPSite(runner, "127.0.0.1", port) + loop.run_until_complete(site.start()) + loop.run_forever() + + +def start_tool_server(port: int = TOOL_SERVER_PORT) -> threading.Thread: + """Start tool server in a background daemon thread. Non-blocking.""" + thread = threading.Thread(target=_run_server, args=(port,), daemon=True, name="tool-server") + thread.start() + return thread From 2576d22a30dfb552eb3c263a0f6d2fbce3694c6b Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Tue, 23 Jun 2026 10:12:17 +0300 Subject: [PATCH 10/13] fix: strengthen cherry-pick AI prompt to prefer incoming changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicitly explain conflict marker semantics (HEAD=target, >>>=cherry-picked). Add concrete rules: keep new methods, use cherry-picked modifications. Prohibit keeping HEAD version — defeats cherry-pick purpose. --- .../libs/handlers/runner_handler.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/webhook_server/libs/handlers/runner_handler.py b/webhook_server/libs/handlers/runner_handler.py index 74e839392..e9781c0f6 100644 --- a/webhook_server/libs/handlers/runner_handler.py +++ b/webhook_server/libs/handlers/runner_handler.py @@ -1302,18 +1302,20 @@ async def _resolve_cherry_pick_with_ai( f"Run git_log with args '--oneline -5' to see recent commit history.\n\n" "## Instructions\n\n" "### Conflict Resolution Rules\n" - "- **Prefer the cherry-picked changes.** Only use HEAD (target branch) when " - "the cherry-picked code references APIs/functions that don't exist on the target branch.\n" - "- Standard conflict markers (<<<<<<< HEAD, =======, >>>>>>>): " - "HEAD is the target branch. Resolve in favor of the cherry-picked changes " - "unless they reference code not present on the target branch.\n" - "- File 'deleted in HEAD and modified in ': This means the file " - "does not exist on the target branch. If the cherry-pick is introducing " - "this file to the target branch, keep the file and 'git add' it. " - "If the file was intentionally removed from the target branch and the " - "changes are not relevant, 'git rm' it.\n" - "- File 'added in both' or 'renamed': Merge the content, keeping both " - "sides' intent.\n\n" + "In cherry-pick conflicts, the conflict markers mean:\n" + "- `<<<<<<< HEAD` = current target branch code (this is the EXISTING code on the target branch)\n" + "- `=======` = separator\n" + "- `>>>>>>> ` = the cherry-picked changes (this is what we WANT to apply)\n\n" + "**ALWAYS prefer the cherry-picked changes (after >>>>>>> marker).** " + "The whole point of cherry-picking is to bring these changes to the target branch. " + "Only fall back to HEAD when the cherry-picked code references APIs, imports, or " + "functions that genuinely don't exist on the target branch.\n\n" + "- If the cherry-picked version adds new methods, parameters, or functionality \u2014 KEEP THEM.\n" + "- If the cherry-picked version modifies existing methods \u2014 USE THE CHERRY-PICKED VERSION.\n" + "- Do NOT simply keep the HEAD version \u2014 that defeats the purpose of the cherry-pick.\n\n" + "- File 'deleted in HEAD and modified in ': The cherry-pick is introducing " + "this file. Keep the file and stage it.\n" + "- File 'added in both' or 'renamed': Merge the content, keeping both sides' intent.\n\n" "After resolving all conflicts, " "make sure the result is syntactically valid.\n\n" "After resolving, read each resolved file to verify your edits are syntactically valid " From dca566e9caadee93114a49e12e013b2430f9063d Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Tue, 23 Jun 2026 10:31:15 +0300 Subject: [PATCH 11/13] fix: address Qodo findings for tool server - Narrow exception handling (ValueError/TypeError instead of Exception) - Validate and clamp tool timeout (1-300s) - Fix conflict marker explanation in cherry-pick prompt - Honest startup log (starting vs started) --- entrypoint.py | 2 +- webhook_server/libs/handlers/runner_handler.py | 3 ++- webhook_server/web/tool_server.py | 10 ++++++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/entrypoint.py b/entrypoint.py index 7472dfea4..f9d43c6bd 100644 --- a/entrypoint.py +++ b/entrypoint.py @@ -67,6 +67,6 @@ def run_podman_cleanup() -> None: # Start tool server on separate event loop (avoids contention with CI checks) start_tool_server() - print(f"\u2705 Tool server started on 127.0.0.1:{TOOL_SERVER_PORT}") + print(f"\u2705 Tool server starting on 127.0.0.1:{TOOL_SERVER_PORT}") uvicorn.run("webhook_server.app:FASTAPI_APP", **uvicorn_kwargs) diff --git a/webhook_server/libs/handlers/runner_handler.py b/webhook_server/libs/handlers/runner_handler.py index e9781c0f6..aabb318bd 100644 --- a/webhook_server/libs/handlers/runner_handler.py +++ b/webhook_server/libs/handlers/runner_handler.py @@ -1305,7 +1305,8 @@ async def _resolve_cherry_pick_with_ai( "In cherry-pick conflicts, the conflict markers mean:\n" "- `<<<<<<< HEAD` = current target branch code (this is the EXISTING code on the target branch)\n" "- `=======` = separator\n" - "- `>>>>>>> ` = the cherry-picked changes (this is what we WANT to apply)\n\n" + "- `>>>>>>> ` = end of the cherry-picked changes\n" + "- The cherry-picked content is BETWEEN `=======` and `>>>>>>> `\n\n" "**ALWAYS prefer the cherry-picked changes (after >>>>>>> marker).** " "The whole point of cherry-picking is to bring these changes to the target branch. " "Only fall back to HEAD when the cherry-picked code references APIs, imports, or " diff --git a/webhook_server/web/tool_server.py b/webhook_server/web/tool_server.py index 9bbc65517..df5993edf 100644 --- a/webhook_server/web/tool_server.py +++ b/webhook_server/web/tool_server.py @@ -67,13 +67,19 @@ async def handle_tool_request(request: web.Request) -> web.Response: """Execute a registered tool.""" try: data: dict[str, Any] = await request.json() - except Exception: - return web.json_response({"detail": "Invalid JSON"}, status=400) + except (ValueError, TypeError) as ex: + return web.json_response({"detail": f"Invalid JSON: {ex}"}, status=400) tool_name = data.get("tool", "") cwd = data.get("cwd", "") args = data.get("args", "") timeout = data.get("timeout") # Caller can override default + if timeout is not None: + try: + timeout = int(timeout) + timeout = max(1, min(timeout, 300)) # Clamp to 1-300 seconds + except (TypeError, ValueError): + timeout = None # Fall back to tool default if not tool_name: return web.json_response({"detail": "Missing 'tool' field"}, status=400) From 603ee0789768699267b1851af749d030610b3f7b Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Tue, 23 Jun 2026 13:24:55 +0300 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20simplify=20cherry-pick=20AI=20prom?= =?UTF-8?q?pt=20=E2=80=94=20let=20the=20AI=20be=20the=20expert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../libs/handlers/runner_handler.py | 44 +++++-------------- webhook_server/tests/test_runner_handler.py | 5 +-- 2 files changed, 11 insertions(+), 38 deletions(-) diff --git a/webhook_server/libs/handlers/runner_handler.py b/webhook_server/libs/handlers/runner_handler.py index aabb318bd..cb1be46b9 100644 --- a/webhook_server/libs/handlers/runner_handler.py +++ b/webhook_server/libs/handlers/runner_handler.py @@ -1283,44 +1283,20 @@ async def _resolve_cherry_pick_with_ai( commit_diff_stat = commit_diff_stat.strip() system_prompt = ( - "You are an expert software engineer resolving git cherry-pick merge conflicts. " - "You have access to file reading and editing tools. " - "Your goal is to resolve all conflicts while preserving the intent of the original commit." + "You are an expert software engineer. Resolve git cherry-pick merge conflicts " + "preserving the intent of the original commit." ) prompt = ( - "You are in a git repository with cherry-pick merge conflicts. " - "Resolve ALL conflicts in ALL files.\n\n" - f"## Original Commit Context\n" - f"**Commit:** `{commit_hash}`\n" - f"**Message:** {commit_message}\n" - f"**Target branch:** `{target_branch}`\n" + "This repository has cherry-pick merge conflicts that need to be resolved.\n\n" + f"**Original commit:** `{commit_hash}`\n" + f"**Commit message:** {commit_message}\n" + f"**Cherry-picking onto branch:** `{target_branch}`\n" f"**PR title:** {pr_title}\n\n" - f"**Original commit changed files:**\n```\n{commit_diff_stat}\n```\n\n" - "Use the git_diff and git_log tools to inspect the original changes if needed.\n" - f"Run git_diff with args '{commit_hash}^..{commit_hash}' to see the original commit diff.\n" - f"Run git_log with args '--oneline -5' to see recent commit history.\n\n" - "## Instructions\n\n" - "### Conflict Resolution Rules\n" - "In cherry-pick conflicts, the conflict markers mean:\n" - "- `<<<<<<< HEAD` = current target branch code (this is the EXISTING code on the target branch)\n" - "- `=======` = separator\n" - "- `>>>>>>> ` = end of the cherry-picked changes\n" - "- The cherry-picked content is BETWEEN `=======` and `>>>>>>> `\n\n" - "**ALWAYS prefer the cherry-picked changes (after >>>>>>> marker).** " - "The whole point of cherry-picking is to bring these changes to the target branch. " - "Only fall back to HEAD when the cherry-picked code references APIs, imports, or " - "functions that genuinely don't exist on the target branch.\n\n" - "- If the cherry-picked version adds new methods, parameters, or functionality \u2014 KEEP THEM.\n" - "- If the cherry-picked version modifies existing methods \u2014 USE THE CHERRY-PICKED VERSION.\n" - "- Do NOT simply keep the HEAD version \u2014 that defeats the purpose of the cherry-pick.\n\n" - "- File 'deleted in HEAD and modified in ': The cherry-pick is introducing " - "this file. Keep the file and stage it.\n" - "- File 'added in both' or 'renamed': Merge the content, keeping both sides' intent.\n\n" - "After resolving all conflicts, " - "make sure the result is syntactically valid.\n\n" - "After resolving, read each resolved file to verify your edits are syntactically valid " - "and semantically consistent with the original commit's purpose." + f"**Files changed in original commit:**\n```\n{commit_diff_stat}\n```\n\n" + "Resolve all conflicts. The goal is to apply the original commit's changes " + "onto the target branch. Use the available tools to inspect the original commit " + "diff, read the conflicted files, and edit them to resolve conflicts." ) self.logger.info(f"{self.log_prefix} Attempting AI conflict resolution with {ai_provider}/{ai_model}") diff --git a/webhook_server/tests/test_runner_handler.py b/webhook_server/tests/test_runner_handler.py index 3c9cb83d0..f69dcd337 100644 --- a/webhook_server/tests/test_runner_handler.py +++ b/webhook_server/tests/test_runner_handler.py @@ -1762,11 +1762,8 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st mock_restore_author.assert_awaited_once() mock_set_success.assert_called_once() mock_ai.assert_called_once() - # Verify prompt includes delete/modify conflict guidance + # Verify prompt includes commit context ai_prompt = str(mock_ai.call_args) - assert "deleted in HEAD and modified in" in ai_prompt, ( - "AI prompt should include delete/modify conflict guidance" - ) # Verify commit context is in the prompt assert "abc123" in ai_prompt, "AI prompt should include commit hash" assert "target_branch" in str(mock_ai.call_args) or "main" in ai_prompt, ( From 10eb56bea3a74ea6130db88b3102b41d840dda57 Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Tue, 23 Jun 2026 14:54:29 +0300 Subject: [PATCH 13/13] fix: harden tool server Broad JSON catch, type validation for cwd/args --- webhook_server/web/tool_server.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/webhook_server/web/tool_server.py b/webhook_server/web/tool_server.py index df5993edf..f7840ffb2 100644 --- a/webhook_server/web/tool_server.py +++ b/webhook_server/web/tool_server.py @@ -67,12 +67,15 @@ async def handle_tool_request(request: web.Request) -> web.Response: """Execute a registered tool.""" try: data: dict[str, Any] = await request.json() - except (ValueError, TypeError) as ex: - return web.json_response({"detail": f"Invalid JSON: {ex}"}, status=400) + except Exception: + return web.json_response({"detail": "Invalid or missing JSON body"}, status=400) tool_name = data.get("tool", "") cwd = data.get("cwd", "") args = data.get("args", "") + + if not isinstance(cwd, str) or not isinstance(args, str): + return web.json_response({"detail": "'cwd' and 'args' must be strings"}, status=400) timeout = data.get("timeout") # Caller can override default if timeout is not None: try: