From 1e35afcf9420680878114f3bd0234bfa03d3d7b7 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 4 Jul 2026 06:39:03 +0800 Subject: [PATCH] Port live AiGateway service architecture Co-Authored-By: Codex --- .github/workflows/monthly-orchestrator.yml | 4 +- .github/workflows/vps_codex_service_ops.yml | 5 + CONTRIBUTING.md | 4 +- README.md | 12 +- README.zh-CN.md | 11 +- SECURITY.md | 4 +- cloudflare/ai-gateway-dash/README.md | 3 + cloudflare/ai-gateway-dash/src/index.mjs | 256 +++++++++--------- .../ai-gateway-dash/tests/index.test.mjs | 7 + cloudflare/codex-audit-proxy/README.md | 6 +- cloudflare/codex-audit-proxy/src/index.mjs | 8 +- .../codex-audit-proxy/tests/index.test.mjs | 7 + docs/async_service_deployment.md | 18 +- scripts/deploy_codex_audit_service.sh | 67 ++++- scripts/run_monthly_codex_audit.py | 75 ++++- service/ai_gateway_service.py | 28 +- service/feedback.py | 9 + service/quota.py | 47 ++++ tests/test_ai_gateway_service_get_routes.py | 83 ++++++ tests/test_monthly_orchestrator_workflow.py | 2 +- tests/test_quota.py | 11 + tests/test_run_monthly_codex_audit.py | 22 ++ 22 files changed, 515 insertions(+), 174 deletions(-) create mode 100644 tests/test_ai_gateway_service_get_routes.py diff --git a/.github/workflows/monthly-orchestrator.yml b/.github/workflows/monthly-orchestrator.yml index 731a6a4b..736b1cf5 100644 --- a/.github/workflows/monthly-orchestrator.yml +++ b/.github/workflows/monthly-orchestrator.yml @@ -117,7 +117,7 @@ jobs: ...targets.map(t => `- ${t}`), '', 'Only snapshot artifact repositories are in scope for monthly report audits.', - 'Each source repository owns its monthly review issue and dispatches CodexAuditBridge with source_repo and issue_number.', + 'Each source repository owns its monthly review issue and dispatches AIAuditBridge with source_repo and issue_number.', '', 'Source of truth for policy and scheduling stays in GitHub Actions.', '', @@ -175,6 +175,6 @@ jobs: echo "- Month: ${{ steps.resolve-month.outputs.month }}" echo "- Tracking issue: ${{ steps.publish-monthly-issue.outputs.issue_url }}" echo - echo "CodexAuditBridge execution requires a source repository issue number." + echo "AIAuditBridge execution requires a source repository issue number." echo "The source repositories create those issues in their own monthly workflows, then dispatch codex_audit.yml with source_repo and issue_number." } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/vps_codex_service_ops.yml b/.github/workflows/vps_codex_service_ops.yml index ba25b433..567a7cd8 100644 --- a/.github/workflows/vps_codex_service_ops.yml +++ b/.github/workflows/vps_codex_service_ops.yml @@ -39,6 +39,11 @@ jobs: - name: Run VPS service operation env: CODEX_AUDIT_SSH_UNBAN_IP: ${{ inputs.ssh_unban_ip }} + CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORIES: QuantStrategyLab/AIAuditBridge,QuantStrategyLab/CodexAuditBridge + CODEX_AUDIT_SERVICE_ALLOWED_WORKFLOW_REFS: QuantStrategyLab/AIAuditBridge/.github/workflows/codex_audit.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/codex_pr_review.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/codex_pr_review.yml@refs/pull/*/merge,QuantStrategyLab/CodexAuditBridge/.github/workflows/codex_audit.yml@refs/heads/main,QuantStrategyLab/CodexAuditBridge/.github/workflows/codex_pr_review.yml@refs/heads/main,QuantStrategyLab/CodexAuditBridge/.github/workflows/codex_pr_review.yml@refs/pull/*/merge + CODEX_AUDIT_SERVICE_ALLOWED_REFS: refs/heads/main,refs/pull/*/merge + CODEX_AUDIT_SERVICE_ALLOWED_SOURCE_REPOSITORIES: QuantStrategyLab/AIAuditBridge,QuantStrategyLab/CodexAuditBridge,QuantStrategyLab/CryptoLivePoolPipelines,QuantStrategyLab/HkEquitySnapshotPipelines,QuantStrategyLab/UsEquitySnapshotPipelines,QuantStrategyLab/ResearchSignalContextPipelines CODEX_AUDIT_SERVICE_MODEL: ${{ vars.CODEX_AUDIT_SERVICE_MODEL }} CODEX_AUDIT_SERVICE_REASONING_EFFORT: ${{ vars.CODEX_AUDIT_SERVICE_REASONING_EFFORT }} + CODEX_AUDIT_SERVICE_TOKEN: ${{ secrets.CODEX_AUDIT_SERVICE_TOKEN }} run: bash scripts/deploy_codex_audit_service.sh "${{ inputs.mode }}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad509f0e..fb45d05b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,13 +2,13 @@ ## 中文摘要 -- 用途:本文档说明如何向 `CodexAuditBridge` 提交低风险、可审阅的变更。 +- 用途:本文档说明如何向 `AIAuditBridge` 提交低风险、可审阅的变更。 - 主要覆盖:`Ground Rules`、`Documentation Standards`、`Branching and Pull Requests`、`Local Verification`。 - 阅读顺序:先确认仓库边界和变更范围,再运行适合本仓库的本地校验。 - 风险提示:涉及策略、artifact、自动化、密钥、云资源、券商或交易所行为的变更,必须先用测试环境、dry-run 或只读证据验证;不要只凭示例修改生产。 - 英文正文保留更完整的命令、字段名和配置键;如果摘要和正文不一致,以正文中的实际命令和配置为准。 -Thanks for contributing to `CodexAuditBridge`. +Thanks for contributing to `AIAuditBridge`. ## Ground Rules diff --git a/README.md b/README.md index e7d4db25..4acf5cb6 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,11 @@ This avoids hard-coding Codex CLI setup in every source repository and avoids de ## Compatibility governance role -Compatibility governance metadata in this repository is ops/control-plane only: +`QuantStrategyLab/AIAuditBridge` is an ops/control-plane consumer only: -- It aligns audit/review execution contracts across QuantStrategyLab repositories. +- It consumes compatibility governance metadata to align audit/review execution. - It must **not** participate in trading runtime dependency graphs or strategy/runtime upgrade flows. -- All governance references from this repo should be interpreted as control-plane/tooling compatibility, not runtime coupling. +- All governance references here are for control-plane operation and should not be interpreted as runtime coupling. ## Supported source repositories @@ -99,12 +99,12 @@ Configure these values in `QuantStrategyLab/AIAuditBridge`: Run the service host with: ```bash -CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORIES=QuantStrategyLab/AIAuditBridge \ -CODEX_AUDIT_SERVICE_ALLOWED_SOURCE_REPOSITORIES='QuantStrategyLab/CryptoLivePoolPipelines,QuantStrategyLab/HkEquitySnapshotPipelines,QuantStrategyLab/UsEquitySnapshotPipelines,QuantStrategyLab/ResearchSignalContextPipelines' \ +CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORIES=QuantStrategyLab/AIAuditBridge,QuantStrategyLab/CodexAuditBridge \ +CODEX_AUDIT_SERVICE_ALLOWED_SOURCE_REPOSITORIES='QuantStrategyLab/AIAuditBridge,QuantStrategyLab/CodexAuditBridge,QuantStrategyLab/CryptoLivePoolPipelines,QuantStrategyLab/HkEquitySnapshotPipelines,QuantStrategyLab/UsEquitySnapshotPipelines,QuantStrategyLab/ResearchSignalContextPipelines' \ CODEX_AUDIT_SERVICE_AUDIENCE=quant-codex-audit \ CODEX_AUDIT_SERVICE_MODEL=gpt-5.4 \ CODEX_AUDIT_SERVICE_REASONING_EFFORT=auto \ -python3 scripts/codex_audit_service.py +python3 -m service.ai_gateway_service ``` Terminate TLS on 443 with the platform load balancer or a reverse proxy and forward `/v1/codex-audit` to the service port. Do not pass GitHub write tokens to this service. diff --git a/README.zh-CN.md b/README.zh-CN.md index 96653c19..748a0720 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -33,12 +33,13 @@ Codex 执行现在只走 service backend:workflow 从 GitHub-hosted runner 调 ## 兼容性治理定位 -本仓库中的兼容性治理元数据只用于 ops/control-plane: +`QuantStrategyLab/AIAuditBridge` 只作为 ops/control-plane 的消费侧参与兼容治理: -- 对齐 QuantStrategyLab 各仓库的审计/评审执行契约; +- 仅消费兼容矩阵和治理元数据,确保审计/评审边界行为一致; - 不参与策略/交易运行时的依赖图、升级决策或 runtime 级联; - 本仓库中的兼容关系只用于审计与 review 运营(control-plane),不应被源仓库当作交易策略运行时依赖。 + ## 支持的 source repository | Source repository | 允许的 task | @@ -75,11 +76,11 @@ AIAuditBridge 只使用 service backend。workflow 运行在 `ubuntu-latest`, service host 启动示例: ```bash -CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORIES=QuantStrategyLab/AIAuditBridge \ -CODEX_AUDIT_SERVICE_ALLOWED_SOURCE_REPOSITORIES='QuantStrategyLab/CryptoLivePoolPipelines,QuantStrategyLab/HkEquitySnapshotPipelines,QuantStrategyLab/UsEquitySnapshotPipelines,QuantStrategyLab/ResearchSignalContextPipelines' \ +CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORIES=QuantStrategyLab/AIAuditBridge,QuantStrategyLab/CodexAuditBridge \ +CODEX_AUDIT_SERVICE_ALLOWED_SOURCE_REPOSITORIES='QuantStrategyLab/AIAuditBridge,QuantStrategyLab/CodexAuditBridge,QuantStrategyLab/CryptoLivePoolPipelines,QuantStrategyLab/HkEquitySnapshotPipelines,QuantStrategyLab/UsEquitySnapshotPipelines,QuantStrategyLab/ResearchSignalContextPipelines' \ CODEX_AUDIT_SERVICE_AUDIENCE=quant-codex-audit \ CODEX_AUDIT_SERVICE_MODEL=gpt-5.4 \ -python3 scripts/codex_audit_service.py +python3 -m service.ai_gateway_service ``` 443/TLS 建议由平台负载均衡或反向代理负责,并把 `/v1/codex-audit` 转发到 service 端口。不要把 GitHub 写 token 传给这个 service。 diff --git a/SECURITY.md b/SECURITY.md index 77e6fd31..5f2be942 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,13 +2,13 @@ ## 中文摘要 -- 用途:本文档说明如何报告 `CodexAuditBridge` 的安全问题,以及密钥或凭证暴露时的处理顺序。 +- 用途:本文档说明如何报告 `AIAuditBridge` 的安全问题,以及密钥或凭证暴露时的处理顺序。 - 主要覆盖:`Reporting a Vulnerability`、`Secret and Credential Exposure`、`Scope Notes`。 - 阅读顺序:发现问题后先避免公开泄露,再通过私密渠道提供最小复现信息。 - 风险提示:涉及实盘、密钥、权限、Cloud Run、GitHub Actions、交易所或券商 API 的问题,不要开公开 issue 或贴出敏感日志。 - 英文正文保留更完整的命令、字段名和配置键;如果摘要和正文不一致,以正文中的实际命令和配置为准。 -Thanks for helping keep `CodexAuditBridge` safe. +Thanks for helping keep `AIAuditBridge` safe. This repository is part of the QuantStrategyLab automation, research, or trading-support surface. Please do **not** open a public issue for vulnerabilities involving credentials, broker or exchange access, cloud resources, workflow tokens, private market data, account identifiers, order execution, or secret material. diff --git a/cloudflare/ai-gateway-dash/README.md b/cloudflare/ai-gateway-dash/README.md index 2b2b1b81..d58a127d 100644 --- a/cloudflare/ai-gateway-dash/README.md +++ b/cloudflare/ai-gateway-dash/README.md @@ -14,6 +14,9 @@ Cloudflare Worker that serves an operations dashboard for the AiGateway service. | `AI_GATEWAY_ORIGIN_URL` | VPS origin URL (e.g. `https://43.156.238.238.sslip.io`) | | `DASHBOARD_API_TOKEN` | Static token for read-only API access | +`DASHBOARD_API_TOKEN` must match the VPS service `CODEX_AUDIT_SERVICE_TOKEN` +so the dashboard can read `/v1/ai/*` endpoints. + ## Deploy ```bash diff --git a/cloudflare/ai-gateway-dash/src/index.mjs b/cloudflare/ai-gateway-dash/src/index.mjs index 8fff9ea6..02c900df 100644 --- a/cloudflare/ai-gateway-dash/src/index.mjs +++ b/cloudflare/ai-gateway-dash/src/index.mjs @@ -30,38 +30,65 @@ const DASHBOARD_API_ROUTES = new Set([ const LOGIN_HTML = ` - -AiGateway · 登录 + + + +AiGateway · QuantStrategyLab + *{margin:0;padding:0;box-sizing:border-box} + body{min-height:100vh;padding:28px;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--text);background:radial-gradient(circle at 18% 80%,rgba(59,130,246,.18),transparent 28%),radial-gradient(circle at 74% 18%,rgba(139,92,246,.16),transparent 24%),linear-gradient(145deg,#050914,#07111d 52%,#04070f);-webkit-font-smoothing:antialiased} + body:before{content:"";position:fixed;inset:0;pointer-events:none;background-image:linear-gradient(rgba(148,163,184,.028) 1px,transparent 1px),linear-gradient(90deg,rgba(148,163,184,.028) 1px,transparent 1px);background-size:46px 46px;mask-image:linear-gradient(to bottom,rgba(0,0,0,.72),transparent)} + .page{position:relative;min-height:calc(100vh - 56px);display:grid;grid-template-rows:auto 1fr auto;max-width:1120px;margin:0 auto;border:1px solid rgba(148,163,184,.10);border-radius:28px;background:linear-gradient(180deg,rgba(7,15,28,.72),rgba(5,9,20,.86));box-shadow:var(--shadow);overflow:hidden} + .page:after{content:"";position:absolute;left:-120px;bottom:-120px;width:440px;height:260px;background:radial-gradient(ellipse at center,rgba(34,211,238,.18),transparent 68%);filter:blur(10px);pointer-events:none} + header{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:24px 28px;border-bottom:1px solid rgba(148,163,184,.09)} + .brand{display:flex;align-items:center;gap:12px;font-weight:780;font-size:20px;letter-spacing:-.04em}.brand span{color:#8aa0bd;font-size:13px;font-weight:500;letter-spacing:0}.mark{width:34px;height:34px;border-radius:12px;display:grid;place-items:center;color:#60a5fa;background:linear-gradient(135deg,rgba(59,130,246,.22),rgba(139,92,246,.18));border:1px solid rgba(96,165,250,.34)}.mark svg{width:20px;height:20px}.top-login{color:#cbd5e1;text-decoration:none;font-size:13px;border:1px solid rgba(148,163,184,.18);border-radius:12px;padding:9px 13px;background:rgba(15,28,47,.62)} + main{display:grid;place-items:center;padding:64px 28px 50px;text-align:center}.hero{width:min(680px,100%)} + h1{font-size:clamp(48px,7vw,76px);line-height:.95;letter-spacing:-.075em;color:#fff;margin-bottom:16px}.org{font-size:clamp(28px,4vw,42px);font-weight:760;letter-spacing:-.055em;background:linear-gradient(90deg,#38bdf8,#8b5cf6);-webkit-background-clip:text;background-clip:text;color:transparent;margin-bottom:16px}.tagline{font-size:18px;color:#d7e2f1;margin-bottom:26px}.desc{max-width:590px;margin:0 auto 38px;color:var(--muted);font-size:15px;line-height:1.9} + .login-title{font-size:28px;font-weight:760;letter-spacing:-.04em;margin-bottom:10px}.login-note{color:var(--muted);font-size:14px;margin-bottom:24px}.btn{display:inline-flex;align-items:center;justify-content:center;gap:11px;min-width:282px;padding:14px 22px;border-radius:13px;background:linear-gradient(135deg,var(--blue),var(--violet));color:#fff;text-decoration:none;font-size:16px;font-weight:760;box-shadow:0 18px 44px rgba(59,130,246,.24);transition:transform .18s ease,box-shadow .18s ease}.btn:hover{transform:translateY(-1px);box-shadow:0 22px 52px rgba(59,130,246,.32)}.btn svg{width:21px;height:21px;fill:currentColor}.limited{margin-top:14px;color:var(--muted2);font-size:13px}.err{display:none;max-width:560px;margin:0 auto 22px;padding:12px 14px;border-radius:13px;background:rgba(248,113,113,.11);border:1px solid rgba(248,113,113,.35);color:var(--danger);font-size:13px;text-align:left} + .capabilities{width:min(560px,100%);margin:42px auto 0;padding:22px;border:1px solid rgba(148,163,184,.16);border-radius:18px;background:linear-gradient(180deg,rgba(15,28,47,.70),rgba(8,16,30,.64));text-align:left}.capabilities h2{font-size:16px;margin-bottom:14px;letter-spacing:-.02em}.capabilities ul{display:grid;gap:11px;list-style:none;color:var(--muted);font-size:14px}.capabilities li{display:flex;gap:10px;align-items:flex-start}.capabilities li:before{content:"";width:16px;height:16px;flex:0 0 16px;margin-top:2px;border-radius:50%;border:1px solid rgba(147,197,253,.55);background:radial-gradient(circle at center,rgba(59,130,246,.65) 0 3px,transparent 4px)} + footer{position:relative;z-index:1;display:flex;justify-content:center;gap:12px;padding:24px 28px;color:var(--muted2);font-size:12px;border-top:1px solid rgba(148,163,184,.08)} + @media(max-width:720px){body{padding:14px}.page{min-height:calc(100vh - 28px);border-radius:22px}header{padding:18px;align-items:flex-start}.brand{align-items:flex-start;flex-direction:column;gap:6px}.top-login{display:none}main{padding:46px 20px 36px}.btn{width:100%;min-width:0}.capabilities{margin-top:32px;padding:18px}footer{flex-direction:column;align-items:center}} + + -
- -

AiGateway

-
QuantStrategyLab · 运维面板
GitHub 组织成员登录
-
- - - 使用 GitHub 登录 - - +
+
+
AiGateway QuantStrategyLab
+ +
+
+
+

AiGateway

+
QuantStrategyLab
+
AI audit gateway operations
+

AiGateway 是 QuantStrategyLab 内部的 AI 审计与网关边界,统一承载 API 使用、配额控制、变更效果评估与影子审计,让 AI 能力在组织边界内安全、可观测、可追溯地运行。

+
+ + + + + 使用 GitHub 登录 + +
仅限 QuantStrategyLab 组织成员访问
+
+

登录后,您可以查看

+
    +
  • 服务整体健康状态与关键接口指标摘要
  • +
  • 各仓库 / 团队的配额消耗与预算概览
  • +
  • 变更有效性评估(90 天)
  • +
  • 影子审计分歧统计与裁决结果
  • +
  • 最近变更记录(7 天)
  • +
+
+
+
+
AiGateway·QuantStrategyLab内部使用 · 安全审计边界
+ `; // ── Helpers ──────────────────────────────────────────────────────────── @@ -274,6 +270,10 @@ function allowedDashboardApiPath(pathname) { return DASHBOARD_API_ROUTES.has(clean) ? clean : ""; } +function shouldIgnoreLegacyEndpointBase(basePath, pathname) { + return basePath === "/v1/codex-audit" && pathname.startsWith("/v1/ai/"); +} + export function buildDashboardApiUrl(rawOrigin, pathname, search = "") { if (!rawOrigin || !rawOrigin.trim()) throw new Error("AI_GATEWAY_ORIGIN_URL not configured"); const clean = allowedDashboardApiPath(pathname); @@ -281,7 +281,7 @@ export function buildDashboardApiUrl(rawOrigin, pathname, search = "") { const origin = new URL(rawOrigin.trim()); if (origin.protocol !== "https:") throw new Error("AI_GATEWAY_ORIGIN_URL must use HTTPS"); const basePath = withoutTrailingSlash(origin.pathname); - origin.pathname = !basePath || basePath === "/" ? clean : basePath + clean; + origin.pathname = !basePath || basePath === "/" || shouldIgnoreLegacyEndpointBase(basePath, clean) ? clean : basePath + clean; origin.search = search; origin.hash = ""; return origin.toString(); diff --git a/cloudflare/ai-gateway-dash/tests/index.test.mjs b/cloudflare/ai-gateway-dash/tests/index.test.mjs index 00fb9571..76a160ab 100644 --- a/cloudflare/ai-gateway-dash/tests/index.test.mjs +++ b/cloudflare/ai-gateway-dash/tests/index.test.mjs @@ -17,6 +17,13 @@ test("buildDashboardApiUrl allows effectiveness route with query", () => { ); }); +test("buildDashboardApiUrl maps AiGateway routes at root when origin is legacy audit endpoint", () => { + assert.equal( + buildDashboardApiUrl("https://origin.example/v1/codex-audit", "/v1/ai/health", ""), + "https://origin.example/v1/ai/health", + ); +}); + test("buildDashboardApiUrl rejects unsupported origin paths", () => { assert.throws( () => buildDashboardApiUrl("https://origin.example", "/v1/ai/execute/jobs", ""), diff --git a/cloudflare/codex-audit-proxy/README.md b/cloudflare/codex-audit-proxy/README.md index 36885c9e..ccf7e5b1 100644 --- a/cloudflare/codex-audit-proxy/README.md +++ b/cloudflare/codex-audit-proxy/README.md @@ -1,6 +1,6 @@ -# Cloudflare Worker Proxy for CodexAuditBridge +# Cloudflare Worker Proxy for AIAuditBridge -This Worker provides a free `workers.dev` HTTPS entry point for CodexAuditBridge when no custom domain is available. +This Worker provides a free `workers.dev` HTTPS entry point for AIAuditBridge when no custom domain is available. This Worker should stay separate from the Pigbibi CodexGateway Worker so origin URLs, repository allowlists, and logs remain isolated. @@ -28,7 +28,7 @@ The production shape is: quantstrategylab-codex-audit-proxy -> VPS HTTPS origin -> codex-audit-service ``` -After deploy, set the `CodexAuditBridge` GitHub secret `CODEX_AUDIT_SERVICE_URL` to the Worker URL, for example: +After deploy, set the `AIAuditBridge` GitHub secret `CODEX_AUDIT_SERVICE_URL` to the Worker URL, for example: ```text https://quantstrategylab-codex-audit-proxy..workers.dev diff --git a/cloudflare/codex-audit-proxy/src/index.mjs b/cloudflare/codex-audit-proxy/src/index.mjs index e5d2a0aa..370e7343 100644 --- a/cloudflare/codex-audit-proxy/src/index.mjs +++ b/cloudflare/codex-audit-proxy/src/index.mjs @@ -1,4 +1,4 @@ -/** CodexAuditBridge Cloudflare proxy — forwards authenticated requests to VPS origin. +/** AIAuditBridge Cloudflare proxy — forwards authenticated requests to VPS origin. Allowed paths (expanded for AiGateway v2): GET /healthz @@ -82,6 +82,10 @@ function withoutTrailingSlash(pathname) { return pathname.replace(/\/+$/, ""); } +function shouldIgnoreLegacyEndpointBase(basePath, pathname) { + return basePath === "/v1/codex-audit" && pathname.startsWith("/v1/ai/"); +} + /** Check if pathname matches any allowed route or is a valid sub-path (jobs/{id}, changes/{id}). */ function matchRoute(pathname) { const clean = withoutTrailingSlash(pathname); @@ -136,7 +140,7 @@ export function buildOriginUrl(rawOriginUrl, pathname, search = "") { throw new Error("CODEX_AUDIT_ORIGIN_URL must use HTTPS"); } const basePath = withoutTrailingSlash(origin.pathname); - if (!basePath || basePath === "/") { + if (!basePath || basePath === "/" || shouldIgnoreLegacyEndpointBase(basePath, pathname)) { origin.pathname = pathname; } else if (pathname === basePath || pathname.startsWith(basePath + "/")) { origin.pathname = pathname; diff --git a/cloudflare/codex-audit-proxy/tests/index.test.mjs b/cloudflare/codex-audit-proxy/tests/index.test.mjs index df160839..8c8b7b5f 100644 --- a/cloudflare/codex-audit-proxy/tests/index.test.mjs +++ b/cloudflare/codex-audit-proxy/tests/index.test.mjs @@ -33,6 +33,13 @@ test("buildOriginUrl maps async status route next to audit endpoint", () => { ); }); +test("buildOriginUrl maps AiGateway routes at root when origin is legacy audit endpoint", () => { + assert.equal( + buildOriginUrl("https://origin.example/v1/codex-audit", "/v1/ai/health"), + "https://origin.example/v1/ai/health", + ); +}); + test("buildOriginUrl preserves nested base path", () => { assert.equal( buildOriginUrl("https://origin.example/codex", "/v1/codex-audit"), diff --git a/docs/async_service_deployment.md b/docs/async_service_deployment.md index 3693949a..cdd045d8 100644 --- a/docs/async_service_deployment.md +++ b/docs/async_service_deployment.md @@ -15,7 +15,7 @@ The synchronous `POST /v1/codex-audit` endpoint remains available for local diag ## Boundary with Pigbibi CodexGateway -`AIAuditBridge` intentionally stays separate from `Pigbibi/AIGateway`. +`AIAuditBridge` intentionally stays separate from `Pigbibi/CodexGateway`. - `CodexGateway` is a generic Codex invocation facade for prompt/context/image/schema calls. - `AIAuditBridge` owns QuantStrategyLab monthly audit semantics: source issue context, bounded repository snapshots, service patch contracts, source repository allowlists, GitHub App writeback, and generated remediation PRs. @@ -29,11 +29,11 @@ The historical self-hosted direct-Codex workflows in `SelfHostedCodexAuditBridge - Source repositories, including public QuantStrategyLab repositories, must not store provider keys or the Codex service URL. - Source repositories should only dispatch the bridge workflow and provide issue/source context. Avoid running Codex directly in public workflows. - `QuantStrategyLab/AIAuditBridge` is public, so it may contain only client/orchestration code. Its service URL, provider fallback keys, GitHub App private key, and Cloudflare origin stay in GitHub or Cloudflare secrets. -- The VPS service should allow only `QuantStrategyLab/AIAuditBridge` in `CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORIES`; this OIDC allowlist is required because the bridge repository is public. -- The VPS service should require `CODEX_AUDIT_SERVICE_ALLOWED_WORKFLOW_REFS=QuantStrategyLab/AIAuditBridge/.github/workflows/codex_audit.yml@refs/heads/main`. -- The VPS service should require `CODEX_AUDIT_SERVICE_ALLOWED_REFS=refs/heads/main`. +- The VPS service should allow `QuantStrategyLab/AIAuditBridge` in `CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORIES`; keep `QuantStrategyLab/CodexAuditBridge` only as temporary migration compatibility. +- The VPS service should require explicit `CODEX_AUDIT_SERVICE_ALLOWED_WORKFLOW_REFS` for the canonical `AIAuditBridge` workflows and any temporary legacy workflow refs. +- The VPS service should keep `CODEX_AUDIT_SERVICE_ALLOWED_REFS` as narrow as the enabled workflows allow, normally `refs/heads/main` plus `refs/pull/*/merge` for PR review smoke tests. - Keep `CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORY_VISIBILITIES=public` unless the bridge repository is intentionally private. -- The VPS service should keep `CODEX_AUDIT_SERVICE_ALLOWED_SOURCE_REPOSITORIES` limited to the current source repositories. +- The VPS service should keep `CODEX_AUDIT_SERVICE_ALLOWED_SOURCE_REPOSITORIES` limited to current audit source repositories and PR review targets. - The Cloudflare Worker stores only `CODEX_AUDIT_ORIGIN_URL` as a Worker secret. Do not commit the origin URL if it exposes infrastructure details. - Job IDs are random and status reads still require service authentication. Job responses never include the original prompt. - Static service bearer tokens are no longer supported; production calls must use GitHub Actions OIDC. @@ -55,11 +55,11 @@ For public source repositories: After merging the async service code, run the manual `VPS Codex Service Ops` workflow with deploy mode, or run on the VPS: ```bash -CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORIES=QuantStrategyLab/AIAuditBridge \ -CODEX_AUDIT_SERVICE_ALLOWED_WORKFLOW_REFS='QuantStrategyLab/AIAuditBridge/.github/workflows/codex_audit.yml@refs/heads/main' \ -CODEX_AUDIT_SERVICE_ALLOWED_REFS='refs/heads/main' \ +CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORIES=QuantStrategyLab/AIAuditBridge,QuantStrategyLab/CodexAuditBridge \ +CODEX_AUDIT_SERVICE_ALLOWED_WORKFLOW_REFS='QuantStrategyLab/AIAuditBridge/.github/workflows/codex_audit.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/codex_pr_review.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/codex_pr_review.yml@refs/pull/*/merge,QuantStrategyLab/CodexAuditBridge/.github/workflows/codex_audit.yml@refs/heads/main,QuantStrategyLab/CodexAuditBridge/.github/workflows/codex_pr_review.yml@refs/heads/main,QuantStrategyLab/CodexAuditBridge/.github/workflows/codex_pr_review.yml@refs/pull/*/merge' \ +CODEX_AUDIT_SERVICE_ALLOWED_REFS='refs/heads/main,refs/pull/*/merge' \ CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORY_VISIBILITIES='public' \ -CODEX_AUDIT_SERVICE_ALLOWED_SOURCE_REPOSITORIES='QuantStrategyLab/CryptoLivePoolPipelines,QuantStrategyLab/HkEquitySnapshotPipelines,QuantStrategyLab/UsEquitySnapshotPipelines,QuantStrategyLab/ResearchSignalContextPipelines' \ +CODEX_AUDIT_SERVICE_ALLOWED_SOURCE_REPOSITORIES='QuantStrategyLab/AIAuditBridge,QuantStrategyLab/CodexAuditBridge,QuantStrategyLab/CryptoLivePoolPipelines,QuantStrategyLab/HkEquitySnapshotPipelines,QuantStrategyLab/UsEquitySnapshotPipelines,QuantStrategyLab/ResearchSignalContextPipelines' \ CODEX_AUDIT_SERVICE_AUDIENCE=quant-codex-audit \ CODEX_AUDIT_SERVICE_MODEL=gpt-5.4 \ CODEX_AUDIT_SERVICE_REASONING_EFFORT=auto \ diff --git a/scripts/deploy_codex_audit_service.sh b/scripts/deploy_codex_audit_service.sh index 8247dd2e..0a97ff58 100644 --- a/scripts/deploy_codex_audit_service.sh +++ b/scripts/deploy_codex_audit_service.sh @@ -8,10 +8,10 @@ DEPLOY_DIR="${CODEX_AUDIT_SERVICE_DEPLOY_DIR:-/opt/codex-audit-bridge}" AUDIT_PORT="${CODEX_AUDIT_SERVICE_PORT:-8797}" AUDIENCE="${CODEX_AUDIT_SERVICE_AUDIENCE:-quant-codex-audit}" ALLOWED_REPOSITORIES="${CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORIES:-QuantStrategyLab/AIAuditBridge,QuantStrategyLab/CodexAuditBridge}" -ALLOWED_WORKFLOW_REFS="${CODEX_AUDIT_SERVICE_ALLOWED_WORKFLOW_REFS:-QuantStrategyLab/AIAuditBridge/.github/workflows/codex_audit.yml@refs/heads/main,QuantStrategyLab/CodexAuditBridge/.github/workflows/codex_audit.yml@refs/heads/main}" -ALLOWED_REFS="${CODEX_AUDIT_SERVICE_ALLOWED_REFS:-refs/heads/main}" +ALLOWED_WORKFLOW_REFS="${CODEX_AUDIT_SERVICE_ALLOWED_WORKFLOW_REFS:-QuantStrategyLab/AIAuditBridge/.github/workflows/codex_audit.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/codex_pr_review.yml@refs/heads/main,QuantStrategyLab/AIAuditBridge/.github/workflows/codex_pr_review.yml@refs/pull/*/merge,QuantStrategyLab/CodexAuditBridge/.github/workflows/codex_audit.yml@refs/heads/main,QuantStrategyLab/CodexAuditBridge/.github/workflows/codex_pr_review.yml@refs/heads/main,QuantStrategyLab/CodexAuditBridge/.github/workflows/codex_pr_review.yml@refs/pull/*/merge}" +ALLOWED_REFS="${CODEX_AUDIT_SERVICE_ALLOWED_REFS:-refs/heads/main,refs/pull/*/merge}" ALLOWED_REPOSITORY_VISIBILITIES="${CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORY_VISIBILITIES:-public}" -ALLOWED_SOURCE_REPOSITORIES="${CODEX_AUDIT_SERVICE_ALLOWED_SOURCE_REPOSITORIES:-QuantStrategyLab/CryptoLivePoolPipelines,QuantStrategyLab/HkEquitySnapshotPipelines,QuantStrategyLab/UsEquitySnapshotPipelines,QuantStrategyLab/ResearchSignalContextPipelines}" +ALLOWED_SOURCE_REPOSITORIES="${CODEX_AUDIT_SERVICE_ALLOWED_SOURCE_REPOSITORIES:-QuantStrategyLab/AIAuditBridge,QuantStrategyLab/CodexAuditBridge,QuantStrategyLab/CryptoLivePoolPipelines,QuantStrategyLab/HkEquitySnapshotPipelines,QuantStrategyLab/UsEquitySnapshotPipelines,QuantStrategyLab/ResearchSignalContextPipelines}" JOB_DIR="${CODEX_AUDIT_SERVICE_JOB_DIR:-/var/lib/codex-audit-bridge/jobs}" AUDIT_MODEL="${CODEX_AUDIT_SERVICE_MODEL:-}" AUDIT_REASONING_EFFORT="${CODEX_AUDIT_SERVICE_REASONING_EFFORT:-}" @@ -48,6 +48,19 @@ systemctl_status_brief() { fi } +systemctl_environment_brief() { + local service="$1" + if systemctl list-unit-files "${service}.service" >/dev/null 2>&1; then + echo "### ${service}.service environment" + systemctl show "$service" --property=Environment --no-pager 2>/dev/null \ + | sed 's/^Environment=//' \ + | tr ' ' '\n' \ + | sed -E "s/^[\"']//; s/[\"']$//" \ + | grep -E '^CODEX_AUDIT_SERVICE_(ALLOWED_|AUDIENCE=|HOST=|PORT=|JOB_DIR=|QUOTA_STORE=|SANDBOX=|MODEL=|REASONING_EFFORT=)' \ + | mask_infra || true + fi +} + sshd_bin() { if command -v sshd >/dev/null 2>&1; then command -v sshd @@ -125,6 +138,10 @@ inspect() { systemctl_status_brief caddy echo + echo "## Codex audit service environment" + systemctl_environment_brief "$AUDIT_SERVICE_NAME" + echo + echo "## SSH access hints" local sshd sshd="$(sshd_bin || true)" @@ -162,6 +179,14 @@ install_file() { sudo install -D -m "$mode" "$source" "$target" } +install_service_package() { + sudo rm -rf "${DEPLOY_DIR}/service" + sudo install -d -m 0755 "${DEPLOY_DIR}/service" + sudo cp -R service/. "${DEPLOY_DIR}/service/" + sudo find "${DEPLOY_DIR}/service" -type d -exec chmod 0755 {} + + sudo find "${DEPLOY_DIR}/service" -type f -exec chmod 0644 {} + +} + write_audit_service_unit() { local runner_user runner_home runner_user="$(id -un)" @@ -174,6 +199,10 @@ write_audit_service_unit() { if [ -n "$AUDIT_REASONING_EFFORT" ]; then audit_reasoning_effort_line="Environment=CODEX_AUDIT_SERVICE_REASONING_EFFORT=${AUDIT_REASONING_EFFORT}" fi + audit_token_line="" + if [ -n "${CODEX_AUDIT_SERVICE_TOKEN:-}" ]; then + audit_token_line="Environment=CODEX_AUDIT_SERVICE_TOKEN=${CODEX_AUDIT_SERVICE_TOKEN}" + fi sudo tee "/etc/systemd/system/${AUDIT_SERVICE_NAME}.service" >/dev/null </dev/null < str: [ "## Codex Audit", "", - "CodexAuditBridge stopped before making repository changes because the service backend failed outside the source PR/test surface.", + "AIAuditBridge stopped before making repository changes because the service backend failed outside the source PR/test surface.", "", f"- Failure category: `{category}`", f"- Detail: `{sanitize_inline_code(reason, 600)}`", @@ -741,7 +741,7 @@ def build_service_repository_context( "## Repository context snapshot", "", "The service backend cannot access the source checkout directly. The following bounded text snapshot is provided " - "by CodexAuditBridge after path filtering.", + "by AIAuditBridge after path filtering.", "", ] total = len("\n".join(parts).encode("utf-8")) @@ -776,7 +776,7 @@ def service_patch_contract_instructions() -> str: [ "## Service patch contract", "", - "You are running behind CodexAuditBridge's service backend. You cannot edit the checkout directly.", + "You are running behind AIAuditBridge's service backend. You cannot edit the checkout directly.", "For review_and_fix mode, return exactly one JSON object and no surrounding prose:", "", "```json", @@ -840,6 +840,16 @@ def codex_service_job_url(service_url: str, job_id: str) -> str: return service_url.rstrip("/") + f"/jobs/{job_id}" +def codex_service_api_url(service_url: str, api_path: str) -> str: + if not api_path.startswith("/v1/ai/"): + raise BridgeError("Codex audit service API path must start with /v1/ai/") + suffix = "/v1/codex-audit" + base = service_url.rstrip("/") + if base.endswith(suffix): + base = base[: -len(suffix)] + return base.rstrip("/") + api_path + + def request_github_oidc_token(audience: str) -> str: request_url = env_value("ACTIONS_ID_TOKEN_REQUEST_URL") request_token = env_value("ACTIONS_ID_TOKEN_REQUEST_TOKEN") @@ -966,6 +976,47 @@ def request_codex_service( raise BridgeError("Codex audit service job timed out before completion") +def register_gateway_change( + *, + source_repo: str, + task: str, + action: str, + risk: str, + changed_paths: list[str], + issue_number: int, + pr_number: int, + pr_url: str, +) -> str: + """Best-effort write to AiGateway closed-loop change registry.""" + raw_url = env_value("CODEX_AUDIT_SERVICE_URL") + if not raw_url: + return "" + try: + service_url = normalize_codex_service_url(raw_url) + audience = env_value("CODEX_AUDIT_SERVICE_AUDIENCE", DEFAULT_SERVICE_AUDIENCE) + request_codex_service_json( + method="POST", + url=codex_service_api_url(service_url, "/v1/ai/feedback/register"), + audience=audience, + payload={ + "source_repository": source_repo, + "task": task, + "action": action, + "risk": risk, + "confidence": 0.0, + "changed_paths": changed_paths, + "before_metrics": {}, + "issue_number": issue_number, + "pr_number": pr_number, + "external_url": pr_url, + }, + timeout_seconds=30, + ) + except BridgeError as exc: + return str(exc) + return "" + + def extract_json_object(text: str) -> dict[str, Any]: stripped = text.strip() fence = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", stripped, flags=re.DOTALL | re.IGNORECASE) @@ -1652,6 +1703,7 @@ def publish_remediation( policy=workspace.baseline_auto_merge_policy, diff_stats=diff_stats, ) + gateway_action = "auto_pr" stale_auto_merge_label_removed = workspace.stale_auto_merge_label_removed stale_auto_merge_label_error = "" if ( @@ -1749,6 +1801,7 @@ def publish_remediation( f"after Bridge classified the change set as `{guard['risk_level']}` risk. " "CI and the source guard own the final merge decision." ) + gateway_action = "auto_merge" except BridgeError as exc: body_lines.append("") body_lines.append(f"Auto-merge was requested but the guarded label could not be added: `{exc}`") @@ -1767,6 +1820,18 @@ def publish_remediation( elif workspace.feedback_retry_pr and workspace.stale_auto_merge_label_skip_reason: body_lines.append("") body_lines.append(stale_auto_merge_label_skip_message(workspace.stale_auto_merge_label_skip_reason)) + gateway_warning = register_gateway_change( + source_repo=source_repo, + task=task, + action=gateway_action, + risk=str(guard_risk.get("risk_level") or ""), + changed_paths=paths, + issue_number=issue_number, + pr_number=int(pr["number"]), + pr_url=str(pr_url), + ) + if gateway_warning: + print(f"warning: AiGateway change registration failed: {gateway_warning[:300]}", file=sys.stderr) post_issue_comment(token, source_repo, issue_number, "\n".join(body_lines)) return 0 @@ -2411,7 +2476,7 @@ def blocked_paths(paths: list[str], *, task: str = DEFAULT_TASK) -> list[str]: def truncate_markdown(text: str, limit: int = 12000) -> str: if len(text) <= limit: return text - return text[:limit] + "\n\n...[truncated by CodexAuditBridge]" + return text[:limit] + "\n\n...[truncated by AIAuditBridge]" def strip_audit_heading(text: str) -> str: diff --git a/service/ai_gateway_service.py b/service/ai_gateway_service.py index 0529ab34..faa1a323 100644 --- a/service/ai_gateway_service.py +++ b/service/ai_gateway_service.py @@ -485,6 +485,7 @@ def _find_active_job_by_dedupe_key(dedupe_key: str) -> dict[str, Any] | None: def _run_job(job_id: str, payload: dict[str, Any]) -> None: + started = time.time() try: job = _read_job(job_id) job["status"] = "running" @@ -511,6 +512,12 @@ def _run_job(job_id: str, payload: dict[str, Any]) -> None: job["failure_category"] = _classify_failure(result.error) job["updated_at"] = _now() _write_job(job) + get_health_monitor().record( + "/v1/ai/execute/jobs/run", + time.time() - started, + job["status"] == "succeeded", + str(job.get("failure_category") or job.get("error") or ""), + ) _audit_log("job_completed", job_id=job_id, status=job["status"], repository=job.get("repository"), task=job.get("task")) except Exception as exc: @@ -523,6 +530,7 @@ def _run_job(job_id: str, payload: dict[str, Any]) -> None: job["error"] = str(exc)[-4000:] job["failure_category"] = _classify_failure(str(exc)) _write_job(job) + get_health_monitor().record("/v1/ai/execute/jobs/run", time.time() - started, False, type(exc).__name__) _audit_log("job_failed", job_id=job_id, error=type(exc).__name__, repository=job.get("repository")) @@ -756,6 +764,7 @@ def _handle_analyze(self, payload: dict[str, Any]) -> None: def _handle_execute_async(self, claims: dict[str, Any], payload: dict[str, Any]) -> None: """POST /v1/ai/execute/jobs — async Codex execution.""" + started = time.time() req = parse_execute_request(payload) # Security: validate source_repository against allowlist @@ -763,24 +772,27 @@ def _handle_execute_async(self, claims: dict[str, Any], payload: dict[str, Any]) if source_repo: _validate_source_repo(source_repo) _validate_source_repo_org(claims, source_repo) + quota_repo = source_repo or str(claims.get("repository") or "unknown") # Quota check quota = get_quota_manager() - qr = quota.check(source_repo, "codex-cli", req.prompt) + qr = quota.check(quota_repo, "codex-cli", req.prompt) if not qr["allowed"]: _json_response(self, HTTPStatus.TOO_MANY_REQUESTS, { "status": "error", "error": qr["reason"], "remaining_usd": qr.get("remaining_usd", 0), }) return - quota.record_execute(source_repo) + quota.record_execute(quota_repo) payload.setdefault("task", TASK_EXECUTE) job = _submit_job(claims, payload) + get_health_monitor().record("/v1/ai/execute/jobs", time.time() - started, True) _json_response(self, HTTPStatus.ACCEPTED, job) def _handle_execute_sync(self, claims: dict[str, Any], payload: dict[str, Any]) -> None: """POST /v1/ai/execute — sync Codex execution (backward compat).""" + started = time.time() req = parse_execute_request(payload) source_repo = str(payload.get("source_repository") or "") if source_repo: @@ -806,6 +818,7 @@ def _handle_execute_sync(self, claims: dict[str, Any], payload: dict[str, Any]) reasoning_effort=reasoning_effort, timeout=req.timeout_seconds, ) + get_health_monitor().record("/v1/ai/execute", time.time() - started, result.success, result.error if not result.success else "") if result.success: _json_response(self, HTTPStatus.OK, {"status": "ok", "output": result.output}) else: @@ -899,16 +912,23 @@ def _handle_review(self, payload: dict[str, Any]) -> None: def _handle_feedback_register(self, claims: dict[str, Any], payload: dict[str, Any]) -> None: """POST /v1/ai/feedback/register — register an autonomous change with pre-metrics.""" change_id = _new_change_id() + source_repo = str(payload.get("source_repository") or "") + if source_repo: + _validate_source_repo(source_repo) + _validate_source_repo_org(claims, source_repo) record = ChangeRecord( change_id=change_id, - repo=str(claims.get("repository", "")), + repo=source_repo or str(claims.get("repository", "")), task=str(payload.get("task", "")), action=str(payload.get("action", "")), confidence=float(payload.get("confidence", 0.0)), risk=str(payload.get("risk", "")), changed_paths=[str(p) for p in payload.get("changed_paths", []) if isinstance(p, str)], before_metrics={str(k): float(v) for k, v in payload.get("before_metrics", {}).items()}, - source_repo=str(payload.get("source_repository", "")), + source_repo=source_repo, + external_url=str(payload.get("external_url", "")), + issue_number=int(payload["issue_number"]) if payload.get("issue_number") is not None else None, + pr_number=int(payload["pr_number"]) if payload.get("pr_number") is not None else None, ) write_change(record) _audit_log("change_registered", change_id=change_id, repo=record.repo, diff --git a/service/feedback.py b/service/feedback.py index 07b96143..1c6b2f5e 100644 --- a/service/feedback.py +++ b/service/feedback.py @@ -108,6 +108,9 @@ class ChangeRecord: effect: str = EFFECT_PENDING effect_detail: str = "" rollback_issue_url: str = "" + external_url: str = "" + issue_number: int | None = None + pr_number: int | None = None created_at: float = field(default_factory=_now) evaluated_at: float | None = None source_repo: str = "" @@ -126,6 +129,9 @@ def to_dict(self) -> dict[str, Any]: "effect": self.effect, "effect_detail": self.effect_detail, "rollback_issue_url": self.rollback_issue_url, + "external_url": self.external_url, + "issue_number": self.issue_number, + "pr_number": self.pr_number, "created_at": self.created_at, "evaluated_at": self.evaluated_at, "source_repo": self.source_repo, @@ -146,6 +152,9 @@ def from_dict(cls, d: dict[str, Any]) -> "ChangeRecord": effect=str(d.get("effect", EFFECT_PENDING)), effect_detail=str(d.get("effect_detail", "")), rollback_issue_url=str(d.get("rollback_issue_url", "")), + external_url=str(d.get("external_url", "")), + issue_number=int(d["issue_number"]) if d.get("issue_number") is not None else None, + pr_number=int(d["pr_number"]) if d.get("pr_number") is not None else None, created_at=float(d.get("created_at", _now())), evaluated_at=float(d.get("evaluated_at", 0)) if d.get("evaluated_at") else None, source_repo=str(d.get("source_repo", "")), diff --git a/service/quota.py b/service/quota.py index b7fb90ab..8089d724 100644 --- a/service/quota.py +++ b/service/quota.py @@ -141,6 +141,16 @@ def __init__(self): self._weekly_budget = DEFAULT_WEEKLY_BUDGET_USD self._repo_budgets: dict[str, dict[str, float]] = {} self._load_config() + self._load_records() + + def _store_path(self) -> Path | None: + path = os.environ.get("CODEX_AUDIT_SERVICE_QUOTA_STORE", "").strip() + if path: + return Path(path) + job_dir = os.environ.get("CODEX_AUDIT_SERVICE_JOB_DIR", "").strip() + if job_dir: + return Path(job_dir) / "quota.json" + return None def _load_config(self) -> None: config_path = os.environ.get("CODEX_AUDIT_SERVICE_QUOTA_CONFIG", "").strip() @@ -156,6 +166,41 @@ def _load_config(self) -> None: if isinstance(raw.get("repo_budgets"), dict): self._repo_budgets = raw["repo_budgets"] + def _load_records(self) -> None: + path = self._store_path() + if path is None: + return + if not path.exists(): + return + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return + records = raw.get("records") if isinstance(raw, dict) else None + if not isinstance(records, dict): + return + self._records = { + repo: QuotaRecord.from_dict(item) + for repo, item in records.items() + if isinstance(repo, str) and isinstance(item, dict) + } + + def _save_records_locked(self) -> None: + path = self._store_path() + if path is None: + return + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + payload = json.dumps( + {"records": {repo: record.to_dict() for repo, record in self._records.items()}}, + ensure_ascii=False, + sort_keys=True, + ).encode("utf-8") + tmp = path.with_suffix(path.suffix + ".tmp") + with open(tmp, "wb") as handle: + handle.write(payload) + os.chmod(tmp, 0o600) + os.replace(tmp, path) + def _reset_if_needed(self, record: QuotaRecord) -> QuotaRecord: now = time.time() if now - record.last_reset_daily > 86400: @@ -227,6 +272,7 @@ def record(self, repo: str, model: str, prompt: str, output: str = "") -> None: record.codex_calls += 1 record.total_cost_usd += cost self._records[repo] = record + self._save_records_locked() def record_execute(self, repo: str) -> None: """Record a codex exec call (flat cost).""" @@ -239,6 +285,7 @@ def record_execute(self, repo: str) -> None: record.codex_calls += 1 record.total_cost_usd += cost self._records[repo] = record + self._save_records_locked() def status(self, repo: str = "") -> dict[str, Any]: """Get quota status for a repo or all repos.""" diff --git a/tests/test_ai_gateway_service_get_routes.py b/tests/test_ai_gateway_service_get_routes.py new file mode 100644 index 00000000..cbbbb6b4 --- /dev/null +++ b/tests/test_ai_gateway_service_get_routes.py @@ -0,0 +1,83 @@ +import json +import os +import tempfile +import threading +import unittest +import urllib.request +from http.server import ThreadingHTTPServer +from unittest.mock import patch + +from service.ai_gateway_service import AiGatewayRequestHandler + + +class AiGatewayGetRoutesTest(unittest.TestCase): + def test_read_routes_accept_query_strings(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + env = { + "CODEX_AUDIT_SERVICE_AUTH": "none", + "CODEX_AUDIT_SERVICE_ALLOW_NO_AUTH_FOR_LOCAL_TESTS": "true", + "CODEX_AUDIT_SERVICE_JOB_DIR": tmp, + } + with patch.dict(os.environ, env, clear=False): + server = ThreadingHTTPServer(("127.0.0.1", 0), AiGatewayRequestHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + base_url = f"http://127.0.0.1:{server.server_port}" + effectiveness_url = f"{base_url}/v1/ai/changes/effectiveness?days=90" + with urllib.request.urlopen(effectiveness_url, timeout=5) as response: + self.assertEqual(response.status, 200) + effectiveness = json.loads(response.read().decode("utf-8")) + self.assertEqual(effectiveness["status"], "ok") + self.assertIn("report", effectiveness) + + quota_url = f"{base_url}/v1/ai/quota?repo=QuantStrategyLab/AIAuditBridge" + with urllib.request.urlopen(quota_url, timeout=5) as response: + self.assertEqual(response.status, 200) + quota = json.loads(response.read().decode("utf-8")) + self.assertEqual(quota["status"], "ok") + self.assertEqual(quota["quota"]["repo"], "QuantStrategyLab/AIAuditBridge") + finally: + server.shutdown() + server.server_close() + + def test_feedback_register_uses_source_repository_for_change_feed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + env = { + "CODEX_AUDIT_SERVICE_AUTH": "none", + "CODEX_AUDIT_SERVICE_ALLOW_NO_AUTH_FOR_LOCAL_TESTS": "true", + "CODEX_AUDIT_SERVICE_JOB_DIR": tmp, + } + with patch.dict(os.environ, env, clear=False): + server = ThreadingHTTPServer(("127.0.0.1", 0), AiGatewayRequestHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + base_url = f"http://127.0.0.1:{server.server_port}" + payload = { + "source_repository": "local/repo", + "task": "monthly_snapshot_audit", + "action": "auto_pr", + "risk": "low", + "changed_paths": ["docs/runbook.md"], + "external_url": "https://example.test/pr/12", + "issue_number": 7, + "pr_number": 12, + } + request = urllib.request.Request( + f"{base_url}/v1/ai/feedback/register", + data=json.dumps(payload).encode("utf-8"), + method="POST", + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(request, timeout=5) as response: + self.assertEqual(response.status, 200) + + with urllib.request.urlopen(f"{base_url}/v1/ai/changes?days=7", timeout=5) as response: + changes = json.loads(response.read().decode("utf-8"))["changes"] + self.assertEqual(changes[0]["repo"], "local/repo") + self.assertEqual(changes[0]["external_url"], "https://example.test/pr/12") + self.assertEqual(changes[0]["pr_number"], 12) + finally: + server.shutdown() + server.server_close() diff --git a/tests/test_monthly_orchestrator_workflow.py b/tests/test_monthly_orchestrator_workflow.py index 03e34243..b8a57c7e 100644 --- a/tests/test_monthly_orchestrator_workflow.py +++ b/tests/test_monthly_orchestrator_workflow.py @@ -34,5 +34,5 @@ def test_monthly_orchestrator_does_not_dispatch_without_source_issue() -> None: assert "peter-evans/repository-dispatch" not in text assert "event-type: monthly-audit" not in text - assert "CodexAuditBridge execution requires a source repository issue number." in text + assert "AIAuditBridge execution requires a source repository issue number." in text assert "source_repo and issue_number" in text diff --git a/tests/test_quota.py b/tests/test_quota.py index bcb64a22..aa18c65d 100644 --- a/tests/test_quota.py +++ b/tests/test_quota.py @@ -149,6 +149,17 @@ def test_get_weekly_budget_respects_repo_overrides(self) -> None: self.manager._repo_budgets["premium/repo"] = {"weekly": 250.0} self.assertEqual(self.manager.get_weekly_budget("premium/repo"), 250.0) + def test_records_persist_to_store(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + store = str(Path(tmp) / "quota.json") + with patch.dict(os.environ, {"CODEX_AUDIT_SERVICE_QUOTA_STORE": store}): + first = QuotaManager() + first.record_execute("test/repo") + second = QuotaManager() + status = second.status("test/repo") + self.assertEqual(status["codex_calls"], 1) + self.assertGreater(status["total_cost_usd"], 0) + class TestQuotaConfigLoading(unittest.TestCase): """Quota configuration from JSON file.""" diff --git a/tests/test_run_monthly_codex_audit.py b/tests/test_run_monthly_codex_audit.py index 561cd546..0edd8362 100644 --- a/tests/test_run_monthly_codex_audit.py +++ b/tests/test_run_monthly_codex_audit.py @@ -28,6 +28,7 @@ build_service_prompt, classify_service_failure, classify_guarded_auto_merge_risk, + codex_service_api_url, codex_service_job_url, codex_service_jobs_url, convert_local_markdown_links, @@ -101,6 +102,12 @@ def test_validate_repo_accepts_owner_repo(self) -> None: with self.subTest(source_repo=source_repo): self.assertEqual(validate_repo(source_repo), source_repo) + def test_codex_service_api_url_maps_legacy_endpoint_to_ai_route(self) -> None: + self.assertEqual( + codex_service_api_url("https://codex.quant.example/v1/codex-audit", "/v1/ai/feedback/register"), + "https://codex.quant.example/v1/ai/feedback/register", + ) + def test_validate_repo_rejects_invalid_values(self) -> None: with self.assertRaises(Exception): validate_repo("QuantStrategyLab/CryptoLivePoolPipelines/extra") @@ -2255,6 +2262,11 @@ def test_vps_ops_workflow_runs_only_manual_self_hosted_ops(self) -> None: self.assertIn("- self-hosted", workflow) self.assertIn("- codex-vps", workflow) self.assertIn('bash scripts/deploy_codex_audit_service.sh "${{ inputs.mode }}"', workflow) + self.assertIn("CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORIES", workflow) + self.assertIn("CODEX_AUDIT_SERVICE_ALLOWED_WORKFLOW_REFS", workflow) + self.assertIn("CODEX_AUDIT_SERVICE_ALLOWED_REFS", workflow) + self.assertIn("CODEX_AUDIT_SERVICE_ALLOWED_SOURCE_REPOSITORIES", workflow) + self.assertIn("QuantStrategyLab/AIAuditBridge,QuantStrategyLab/CodexAuditBridge,QuantStrategyLab/CryptoLivePoolPipelines", workflow) self.assertIn("actions/checkout@v6.0.3", workflow) def test_vps_deploy_adds_nginx_audit_route_without_router_service(self) -> None: @@ -2263,10 +2275,20 @@ def test_vps_deploy_adds_nginx_audit_route_without_router_service(self) -> None: self.assertIn("location = /v1/codex-audit", deploy_script) self.assertIn("location ^~ /v1/codex-audit/", deploy_script) self.assertIn("CODEX_AUDIT_SERVICE_JOB_DIR", deploy_script) + self.assertIn("codex_pr_review.yml@refs/pull/*/merge", deploy_script) + self.assertIn("refs/pull/*/merge", deploy_script) + self.assertIn("QuantStrategyLab/AIAuditBridge,QuantStrategyLab/CodexAuditBridge,QuantStrategyLab/CryptoLivePoolPipelines", deploy_script) self.assertIn("proxy_pass http://127.0.0.1:{port}", deploy_script) self.assertIn('"# CodexAuditBridge route start" not in block', deploy_script) self.assertIn("audit service did not become healthy", deploy_script) self.assertIn("nginx config test failed; restoring previous config", deploy_script) + self.assertIn("zzzz-managed-allowlists.conf", deploy_script) + self.assertIn('Environment="CODEX_AUDIT_SERVICE_ALLOWED_REPOSITORIES=${ALLOWED_REPOSITORIES}"', deploy_script) + self.assertIn("systemctl_environment_brief", deploy_script) + self.assertIn('sed -E "s/^[\\"', deploy_script) + self.assertIn('s/[\\"\']$//"', deploy_script) + self.assertIn("CODEX_AUDIT_SERVICE_(ALLOWED_|AUDIENCE=", deploy_script) + self.assertNotIn("^CODEX_AUDIT_SERVICE_TOKEN", deploy_script) self.assertNotIn("CODEX_SERVICE_ROUTER", deploy_script) self.assertNotIn("codex_service_router", deploy_script)