diff --git a/apps/dashboard/messages/en.json b/apps/dashboard/messages/en.json index effd80f8..df752273 100644 --- a/apps/dashboard/messages/en.json +++ b/apps/dashboard/messages/en.json @@ -580,6 +580,7 @@ "cost": "Cost", "failure": "Failure code", "dataset": "Frozen dataset", + "llmSnapshot": "Frozen LLM & pricing", "seedReport": "Seed report", "baseline": "Market baseline", "notAvailable": "Not available yet", diff --git a/apps/dashboard/messages/zh.json b/apps/dashboard/messages/zh.json index 1174327c..5014bdfc 100644 --- a/apps/dashboard/messages/zh.json +++ b/apps/dashboard/messages/zh.json @@ -580,6 +580,7 @@ "cost": "费用", "failure": "失败码", "dataset": "冻结数据集", + "llmSnapshot": "冻结 LLM 与定价", "seedReport": "种子报告", "baseline": "市场基准", "notAvailable": "尚不可用", diff --git a/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts index dcfd4746..70547db0 100644 --- a/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts +++ b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts @@ -32,6 +32,7 @@ async function token( provider: "deepseek", operation_id: "operation-1", llm_config_digest: "a".repeat(64), + request_digest: "b".repeat(64), ...overrides, }) .setProtectedHeader({ alg: "EdDSA" }) @@ -76,6 +77,7 @@ describe("internal owner LLM credential route", () => { const now = Math.floor(Date.now() / 1_000); const requests = [ callRoute(`Bearer ${await token({ token_use: "session" })}`), + callRoute(`Bearer ${await token({ request_digest: "missing-scope" })}`), callRoute(`Bearer ${await token({}, { issuedAt: null })}`), callRoute(`Bearer ${await token({}, { issuedAt: now - 108_100, expiresAt: now + 1 })}`), callRoute(`Bearer ${await token({}, { issuedAt: now + 60, expiresAt: now + 120 })}`), @@ -84,6 +86,7 @@ describe("internal owner LLM credential route", () => { ]; expect((await Promise.all(requests)).map((response) => response.status)).toEqual([ + 403, 403, 401, 403, diff --git a/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts index 3ec62a40..e351310f 100644 --- a/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts +++ b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts @@ -42,6 +42,8 @@ export async function GET( payload.operation_id.length < 8 || typeof payload.llm_config_digest !== "string" || !/^[0-9a-f]{64}$/.test(payload.llm_config_digest) || + typeof payload.request_digest !== "string" || + !/^[0-9a-f]{64}$/.test(payload.request_digest) || typeof payload.jti !== "string" || !payload.jti || typeof payload.sub !== "string" || @@ -68,8 +70,8 @@ export async function GET( try { recorded = await getPool().query( `INSERT INTO evolution_credential_grant_uses - (jti,owner_sub,config_id,operation_id,config_digest,consumed_at) - VALUES ($1,$2,$3,$4,$5,NOW()) + (jti,owner_sub,config_id,operation_id,config_digest,request_digest,consumed_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) ON CONFLICT (jti) DO UPDATE SET redemption_count=evolution_credential_grant_uses.redemption_count+1, last_redeemed_at=NOW() @@ -77,6 +79,7 @@ export async function GET( AND evolution_credential_grant_uses.config_id=EXCLUDED.config_id AND evolution_credential_grant_uses.operation_id=EXCLUDED.operation_id AND evolution_credential_grant_uses.config_digest=EXCLUDED.config_digest + AND evolution_credential_grant_uses.request_digest=EXCLUDED.request_digest AND evolution_credential_grant_uses.redemption_count<2 AND evolution_credential_grant_uses.consumed_at>=NOW()-INTERVAL '2 minutes' RETURNING jti`, @@ -86,6 +89,7 @@ export async function GET( configId, payload.operation_id, payload.llm_config_digest, + payload.request_digest, ], ); } catch { diff --git a/apps/dashboard/src/components/evolution/EvolutionRunData.tsx b/apps/dashboard/src/components/evolution/EvolutionRunData.tsx index 910b4e4d..5468245b 100644 --- a/apps/dashboard/src/components/evolution/EvolutionRunData.tsx +++ b/apps/dashboard/src/components/evolution/EvolutionRunData.tsx @@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"; import type { EvolutionRun } from "@/lib/types"; import { Panel } from "@/components/ui/Panel"; -/** 展示 seed、基准、冻结数据 manifest 与失败信息。 */ +/** 展示 seed、冻结 LLM/数据快照、基准与失败信息。 */ export function EvolutionRunData({ run }: { run: EvolutionRun }) { const t = useTranslations("evolution.detail"); return ( @@ -25,6 +25,13 @@ export function EvolutionRunData({ run }: { run: EvolutionRun }) {

)} + + + | null { + const snapshot = run.llm_snapshot; + if (!snapshot) return null; + return { + provider: snapshot.provider, + model: snapshot.model, + config_id: snapshot.config_id, + pricing_version: snapshot.pricing.version, + estimated_max_usd_per_candidate: snapshot.pricing.estimated_max_usd_per_candidate, + config_digest: run.llm_config_digest ?? snapshot.config_digest, + }; +} + function Item({ label, value }: { label: string; value: string }) { return
{label}
{value}
; } diff --git a/apps/dashboard/src/lib/types.ts b/apps/dashboard/src/lib/types.ts index 8395056a..2e8f5f54 100644 --- a/apps/dashboard/src/lib/types.ts +++ b/apps/dashboard/src/lib/types.ts @@ -540,6 +540,23 @@ export type EvolutionRunStatus = | "failed" | "aborted"; +export interface EvolutionLLMSnapshot { + config_id: string; + provider: "deepseek" | "openai" | "kimi" | "zhipu"; + model: string; + base_url: string | null; + pricing: { + version: string; + currency: "USD"; + input_usd_per_million: number; + output_usd_per_million: number; + assumed_input_tokens: number; + max_output_tokens: number; + estimated_max_usd_per_candidate: number; + }; + config_digest: string; +} + export interface EvolutionCandidateSummary { candidate_id: string; run_id: string; @@ -568,6 +585,8 @@ export interface EvolutionRunSummary { seed_strategy_id: string; budget: number; config: Record; + llm_snapshot: EvolutionLLMSnapshot | null; + llm_config_digest: string | null; status: EvolutionRunStatus; active_stage: string | null; llm_cost_usd: number; diff --git a/apps/dashboard/src/proxy.test.ts b/apps/dashboard/src/proxy.test.ts new file mode 100644 index 00000000..af723d3f --- /dev/null +++ b/apps/dashboard/src/proxy.test.ts @@ -0,0 +1,43 @@ +import { NextRequest } from "next/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next-intl/middleware", () => ({ + default: () => vi.fn(() => new Response(null, { status: 200 })), +})); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +/** Load middleware after setting production auth flags because they are module constants. */ +async function productionMiddleware() { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("AUTH_ENABLED", "true"); + return (await import("./proxy")).default; +} + +describe("dashboard production proxy", () => { + it("lets the exact internal credential exchange reach its Ed25519 route verifier", async () => { + const middleware = await productionMiddleware(); + const response = await middleware( + new NextRequest("http://dashboard.test/api/internal/llm-config/config-1", { + headers: { Authorization: "Bearer signed-grant" }, + }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("x-middleware-next")).toBe("1"); + }); + + it("keeps other cookie-less API paths behind the session gate", async () => { + const middleware = await productionMiddleware(); + const response = await middleware( + new NextRequest("http://dashboard.test/api/evolution", { + headers: { Authorization: "Bearer signed-grant" }, + }), + ); + + expect(response.status).toBe(401); + }); +}); diff --git a/apps/dashboard/src/proxy.ts b/apps/dashboard/src/proxy.ts index bed66b32..91f56fa0 100644 --- a/apps/dashboard/src/proxy.ts +++ b/apps/dashboard/src/proxy.ts @@ -42,7 +42,11 @@ export default async function middleware(req: NextRequest): Promise { const { pathname } = req.nextUrl; const isApi = pathname.startsWith("/api"); // 公开:登录页 + 登录/登出/会话 API(否则登录前无从进入)。 - const isPublic = pathname === "/login" || pathname.startsWith("/api/auth/"); + // Evolver 凭据兑换只接受路由内自行验签的 Ed25519 Bearer grant,不依赖浏览器 cookie。 + const isCredentialExchange = + req.method === "GET" && /^\/api\/internal\/llm-config\/[^/]+$/.test(pathname); + const isPublic = + pathname === "/login" || pathname.startsWith("/api/auth/") || isCredentialExchange; if (AUTH_ENABLED && !isPublic && !(await hasValidSession(req))) { if (isApi) { diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index c714a9f8..5afb6b59 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -172,6 +172,7 @@ services: EVOLVER_LLM_TIMEOUT_S: ${EVOLVER_LLM_TIMEOUT_S:-120} # 签名私钥只属于 orchestration;覆盖 svc-common env_file,防 Evolver 自签 owner grant。 EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64: "" + EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64: ${EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64:?required} deploy: replicas: 1 depends_on: diff --git a/infra/migrations/tests/test_migration_0042.py b/infra/migrations/tests/test_migration_0042.py new file mode 100644 index 00000000..65526c6e --- /dev/null +++ b/infra/migrations/tests/test_migration_0042.py @@ -0,0 +1,108 @@ +"""0042 durable evolution approval operation ledger integration tests.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import psycopg +import pytest +from migration_0038_support import alembic, db_url + +pytestmark = pytest.mark.integration + + +def test_0042_persists_one_operation_per_owner_thread_request( + migration_db_url: str, +) -> None: + alembic(migration_db_url, "upgrade", "0041") + old_grant = ( + "11111111-1111-4111-8111-111111111111", + "user:alice", + "config-1", + "operation-old", + "a" * 64, + ) + with psycopg.connect(db_url(migration_db_url), autocommit=True) as conn: + conn.execute( + """INSERT INTO evolution_credential_grant_uses + (jti,owner_sub,config_id,operation_id,config_digest) + VALUES (%s,%s,%s,%s,%s)""", + old_grant, + ) + + alembic(migration_db_url, "upgrade", "0042") + identity = ("user:alice", "thread-1", "evolver.run_evolution", "a" * 64) + first = "50000000-0000-4000-8000-000000000001" + second = "50000000-0000-4000-8000-000000000002" + expires = datetime.now(UTC) + timedelta(hours=24) + with psycopg.connect(db_url(migration_db_url), autocommit=True) as conn: + assert conn.execute( + """SELECT request_digest FROM evolution_credential_grant_uses + WHERE jti=%s""", + (old_grant[0],), + ).fetchone() == ("0" * 64,) + conn.execute( + """INSERT INTO evolution_credential_grant_uses + (jti,owner_sub,config_id,operation_id,config_digest) + VALUES ('11111111-1111-4111-8111-111111111112', + 'user:alice','config-1','operation-rolling',%s)""", + ("a" * 64,), + ) + assert conn.execute( + """SELECT request_digest FROM evolution_credential_grant_uses + WHERE jti='11111111-1111-4111-8111-111111111112'""" + ).fetchone() == ("0" * 64,) + conn.execute( + """INSERT INTO strategy_evo_runs + (run_id,owner_account_id,requested_by_sub,seed_strategy_id,budget,config, + status,idempotency_key,request_hash,queued_at) + VALUES ('50000000-0000-4000-8000-000000000010', + '00000000-0000-0000-0000-000000000099','user:alice','seed',1,'{}', + 'queued','old-image-compatible','old-hash',NOW())""" + ) + assert conn.execute( + """SELECT llm_snapshot_required,llm_credential_grant_required + FROM strategy_evo_runs + WHERE run_id='50000000-0000-4000-8000-000000000010'""" + ).fetchone() == (False, False) + with pytest.raises(psycopg.errors.CheckViolation): + conn.execute( + """INSERT INTO strategy_evo_runs + (run_id,owner_account_id,requested_by_sub,seed_strategy_id,budget,config, + status,idempotency_key,request_hash,queued_at, + llm_snapshot_required,llm_credential_grant_required) + VALUES ('50000000-0000-4000-8000-000000000011', + '00000000-0000-0000-0000-000000000099','user:alice','seed',1,'{}', + 'queued','new-image-invalid','new-hash',NOW(),TRUE,TRUE)""" + ) + conn.execute( + """INSERT INTO evolution_approval_operations + (operation_id,auth_sub,session_id,tool_name,input_digest,expires_at) + VALUES (%s,%s,%s,%s,%s,%s)""", + (first, *identity, expires), + ) + conn.execute( + """INSERT INTO evolution_approval_operations + (operation_id,auth_sub,session_id,tool_name,input_digest,expires_at) + VALUES (%s,%s,%s,%s,%s,%s) + ON CONFLICT (auth_sub,session_id,tool_name,input_digest) DO UPDATE SET + operation_id=EXCLUDED.operation_id,approved_at=NOW(),expires_at=EXCLUDED.expires_at""", + (second, *identity, expires), + ) + assert conn.execute( + """SELECT operation_id::text FROM evolution_approval_operations + WHERE auth_sub=%s AND session_id=%s AND tool_name=%s AND input_digest=%s + AND expires_at>NOW()""", + identity, + ).fetchone() == (second,) + + alembic(migration_db_url, "downgrade", "0041") + with psycopg.connect(db_url(migration_db_url), autocommit=True) as conn: + assert conn.execute( + "SELECT to_regclass('evolution_approval_operations')" + ).fetchone() == (None,) + assert conn.execute( + """SELECT 1 FROM information_schema.columns + WHERE table_name='evolution_credential_grant_uses' + AND column_name='request_digest'""" + ).fetchone() is None diff --git a/infra/migrations/versions/0042_evolution_approval_operations.py b/infra/migrations/versions/0042_evolution_approval_operations.py new file mode 100644 index 00000000..c84ec28d --- /dev/null +++ b/infra/migrations/versions/0042_evolution_approval_operations.py @@ -0,0 +1,100 @@ +"""Persist restart-stable evolution approval operation identities.""" + +from __future__ import annotations + +from alembic import op + +revision: str = "0042" +down_revision: str | None = "0041" +branch_labels: str | tuple[str, ...] | None = None +depends_on: str | tuple[str, ...] | None = None + + +def upgrade() -> None: + """Add request scope, restore rolling compatibility, and create the operation ledger.""" + op.execute( + """SET LOCAL lock_timeout = '10s'; +ALTER TABLE evolution_credential_grant_uses +ADD COLUMN request_digest TEXT; +UPDATE evolution_credential_grant_uses SET request_digest=repeat('0',64); +ALTER TABLE evolution_credential_grant_uses +ALTER COLUMN request_digest SET DEFAULT repeat('0',64), +ALTER COLUMN request_digest SET NOT NULL, +ADD CONSTRAINT evolution_credential_grant_request_digest_check +CHECK (request_digest ~ '^[0-9a-f]{64}$'); +ALTER TABLE strategy_evo_runs +ALTER COLUMN llm_snapshot_required SET DEFAULT FALSE, +ALTER COLUMN llm_credential_grant_required SET DEFAULT FALSE, +DROP CONSTRAINT evo_run_llm_snapshot_check; +ALTER TABLE strategy_evo_runs ADD CONSTRAINT evo_run_llm_snapshot_check +CHECK ( + (NOT llm_credential_grant_required OR llm_snapshot_required) + AND (status <> 'queued' OR NOT llm_snapshot_required OR llm_credential_grant_required) + AND ( + NOT llm_snapshot_required + OR ( + llm_snapshot IS NOT NULL + AND llm_config_digest IS NOT NULL + AND ( + NOT llm_credential_grant_required + OR length(COALESCE(llm_credential_grant,''))>=100 + ) + AND llm_config_digest ~ '^[0-9a-f]{64}$' + AND COALESCE(llm_snapshot->>'config_digest'=llm_config_digest,FALSE) + AND length(COALESCE(llm_snapshot->>'config_id',''))>0 + AND length(COALESCE(llm_snapshot->>'provider',''))>0 + AND length(COALESCE(llm_snapshot->>'model',''))>0 + AND COALESCE(jsonb_typeof(llm_snapshot->'pricing')='object',FALSE) + ) + ) +) NOT VALID; +CREATE TABLE evolution_approval_operations ( + operation_id UUID PRIMARY KEY, + auth_sub TEXT NOT NULL, + session_id TEXT NOT NULL, + tool_name TEXT NOT NULL CHECK (tool_name='evolver.run_evolution'), + input_digest TEXT NOT NULL CHECK (input_digest ~ '^[0-9a-f]{64}$'), + approved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + UNIQUE(auth_sub,session_id,tool_name,input_digest), + CHECK (expires_at>approved_at) +); +CREATE INDEX ix_evolution_approval_operations_expires_at +ON evolution_approval_operations(expires_at);""" + ) + + +def downgrade() -> None: + """Restore the 0041 constraint/defaults and remove new ledgers/scopes.""" + op.execute( + """SET LOCAL lock_timeout = '10s'; +DROP TABLE evolution_approval_operations; +ALTER TABLE strategy_evo_runs +ALTER COLUMN llm_snapshot_required SET DEFAULT TRUE, +ALTER COLUMN llm_credential_grant_required SET DEFAULT TRUE, +DROP CONSTRAINT evo_run_llm_snapshot_check; +ALTER TABLE strategy_evo_runs ADD CONSTRAINT evo_run_llm_snapshot_check +CHECK ( + (status <> 'queued' OR (llm_snapshot_required AND llm_credential_grant_required)) + AND ( + NOT llm_snapshot_required + OR ( + llm_snapshot IS NOT NULL + AND llm_config_digest IS NOT NULL + AND ( + NOT llm_credential_grant_required + OR length(COALESCE(llm_credential_grant,''))>=100 + ) + AND llm_config_digest ~ '^[0-9a-f]{64}$' + AND COALESCE(llm_snapshot->>'config_digest'=llm_config_digest,FALSE) + AND length(COALESCE(llm_snapshot->>'config_id',''))>0 + AND length(COALESCE(llm_snapshot->>'provider',''))>0 + AND length(COALESCE(llm_snapshot->>'model',''))>0 + AND COALESCE(jsonb_typeof(llm_snapshot->'pricing')='object',FALSE) + ) + ) +) NOT VALID; +ALTER TABLE evolution_credential_grant_uses +DROP CONSTRAINT evolution_credential_grant_request_digest_check, +DROP COLUMN request_digest;""" + ) diff --git a/packages/orchestration/src/clients/evolver.ts b/packages/orchestration/src/clients/evolver.ts index bc7c1229..9a8d68a3 100644 --- a/packages/orchestration/src/clients/evolver.ts +++ b/packages/orchestration/src/clients/evolver.ts @@ -1,4 +1,6 @@ /** services/evolver 的 owner-scoped API 客户端。 */ +import { createHash } from "node:crypto"; + import { HttpClient, HttpClientError } from "./http.js"; import type { EvolutionLLMSnapshot } from "../mastra/llm/evolution-snapshot.js"; @@ -13,6 +15,66 @@ export type EvolutionConfig = { validation_split?: number; }; +export type EvolutionStartRequest = { + budget: number; + seed_strategy_id: string; + config: Required; + llm: EvolutionLLMSnapshot; +}; + +/** 将 tool 输入收口为签名与发送共用的唯一请求体。 */ +export function buildEvolutionStartRequest(options: { + budget?: number; + seedStrategyId?: string; + config: EvolutionConfig; + llmSnapshot: EvolutionLLMSnapshot; +}): EvolutionStartRequest { + return { + budget: options.budget ?? 4, + seed_strategy_id: options.seedStrategyId ?? "sma_cross_v1", + config: { + ...options.config, + from_ts: new Date(options.config.from_ts).toISOString(), + as_of: new Date(options.config.as_of).toISOString(), + initial_cash: options.config.initial_cash ?? 10_000, + fee_rate: options.config.fee_rate ?? 0.001, + validation_split: options.config.validation_split ?? 0.3, + }, + llm: options.llmSnapshot, + }; +} + +/** 生成与 Python 端一致的审批请求摘要,覆盖所有会影响成本或结果的字段。 */ +export function evolutionRequestDigest(request: EvolutionStartRequest): string { + const config = request.config; + const canonical = [ + request.seed_strategy_id, + numberText(request.budget), + config.venue, + config.symbol, + config.timeframe, + numberText(Date.parse(config.from_ts)), + numberText(Date.parse(config.as_of)), + float64Hex(config.initial_cash), + float64Hex(config.fee_rate), + float64Hex(config.validation_split), + request.llm.config_digest, + ]; + return createHash("sha256").update(JSON.stringify(canonical)).digest("hex"); +} + +function numberText(value: number): string { + if (!Number.isFinite(value)) throw new Error("evolution request contains a non-finite number"); + return String(value); +} + +function float64Hex(value: number): string { + if (!Number.isFinite(value)) throw new Error("evolution request contains a non-finite number"); + const buffer = Buffer.allocUnsafe(8); + buffer.writeDoubleBE(value); + return buffer.toString("hex"); +} + export type CandidateResult = { candidate_id: string; run_id: string; @@ -41,6 +103,8 @@ export type RunStatusResult = { seed_strategy_id: string; budget: number; config: Record; + llm_snapshot: EvolutionLLMSnapshot | null; + llm_config_digest: string | null; status: "queued" | "running" | "cancelling" | "completed" | "failed" | "aborted"; active_stage: string | null; llm_cost_usd: number; @@ -68,30 +132,19 @@ export class EvolverClient { } async startRun(options: { - budget?: number; - seedStrategyId?: string; - config: EvolutionConfig; + request: EvolutionStartRequest; idempotencyKey: string; - approvalToken: string; credentialGrant: string; - llmSnapshot: EvolutionLLMSnapshot; }): Promise { - const body = { - budget: options.budget ?? 4, - seed_strategy_id: options.seedStrategyId ?? "sma_cross_v1", - config: options.config, - llm: options.llmSnapshot, - }; const headers = { "Idempotency-Key": options.idempotencyKey, - "X-Evolution-Approval": options.approvalToken, "X-Evolution-Credential": options.credentialGrant, }; try { - return await this.http.post("/api/v1/runs", body, headers); + return await this.http.post("/api/v1/runs", options.request, headers); } catch (error) { if (!(error instanceof HttpClientError) || ![502, 504].includes(error.status)) throw error; - return await this.http.post("/api/v1/runs", body, headers); + return await this.http.post("/api/v1/runs", options.request, headers); } } diff --git a/packages/orchestration/src/hooks/with-hooks.ts b/packages/orchestration/src/hooks/with-hooks.ts index 4d0920a7..e26d2e99 100644 --- a/packages/orchestration/src/hooks/with-hooks.ts +++ b/packages/orchestration/src/hooks/with-hooks.ts @@ -222,7 +222,7 @@ export function withHooks(tool: T, opts: WithHooksOptions }; } - const operationId = store.consumeApproved({ + const operationId = await store.consumeApproved({ authSub, sessionId, toolName, diff --git a/packages/orchestration/src/mastra/llm/evolution-credential-grant.ts b/packages/orchestration/src/mastra/llm/evolution-credential-grant.ts index b0374e41..97055905 100644 --- a/packages/orchestration/src/mastra/llm/evolution-credential-grant.ts +++ b/packages/orchestration/src/mastra/llm/evolution-credential-grant.ts @@ -16,6 +16,7 @@ const GRANT_TTL_SECONDS = 30 * 60 * 60; export async function mintEvolutionCredentialGrant(args: { authSub: string; operationId: string; + requestDigest: string; snapshot: EvolutionLLMSnapshot; }): Promise { const encoded = process.env.EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64?.trim(); @@ -38,11 +39,12 @@ export async function mintEvolutionCredentialGrant(args: { config_id: args.snapshot.config_id, provider: args.snapshot.provider, operation_id: args.operationId, + request_digest: args.requestDigest, llm_config_digest: args.snapshot.config_digest, }) .setProtectedHeader({ alg: "EdDSA", typ: "JWT" }) .setSubject(args.authSub) - .setAudience(GRANT_AUDIENCE) + .setAudience(["inalpha-evolver", GRANT_AUDIENCE]) .setJti(randomUUID()) .setIssuedAt(now) .setExpirationTime(now + GRANT_TTL_SECONDS) diff --git a/packages/orchestration/src/permissions/pending.ts b/packages/orchestration/src/permissions/pending.ts index 9c33bc2a..02a0892b 100644 --- a/packages/orchestration/src/permissions/pending.ts +++ b/packages/orchestration/src/permissions/pending.ts @@ -1,6 +1,11 @@ import { createHash, randomUUID } from "node:crypto"; -import { insertPending, markResolved } from "./repo.js"; +import { + findEvolutionOperation, + insertPending, + markResolved, + rememberEvolutionOperation, +} from "./repo.js"; export type PendingDecision = "allow" | "deny"; @@ -39,11 +44,12 @@ interface PendingApprovalRecord extends PendingApprovalView { interface ConsumedApprovalRecord { operationId: string; - deadline: string; + expiresAt: string; timer: ReturnType; } const DEFAULT_TIMEOUT_MS = 30_000; +const EVOLUTION_OPERATION_RETENTION_MS = 24 * 60 * 60 * 1_000; export type PendingTelemetrySink = (record: Record) => void; @@ -58,6 +64,20 @@ export interface ApprovalPersistence { decision: PendingDecision, via: "user" | "timeout", ): Promise; + rememberEvolutionOperation(args: EvolutionOperationScope & { operationId: string }): Promise<{ + expiresAt: string; + } | undefined>; + findEvolutionOperation(args: EvolutionOperationScope): Promise<{ + operationId: string; + expiresAt: string; + } | undefined>; +} + +export interface EvolutionOperationScope { + authSub: string; + sessionId: string; + toolName: string; + inputDigest: string; } /** Produces a deterministic SHA-256 digest for an approval-defining input. */ @@ -147,12 +167,12 @@ export class PendingApprovalsStore { return true; } - /** Atomically consumes one approved decision and returns its stable operation ID. */ - consumeApproved(args: PendingConsumeArgs): string | undefined { + /** Atomically consumes one approved decision and returns its restart-stable operation ID. */ + async consumeApproved(args: PendingConsumeArgs): Promise { const identity = this.identityFor(args); const consumed = args.reuseAfterConsume ? this.consumedByIdentity.get(identity) : undefined; if (consumed) { - if (Date.now() >= Date.parse(consumed.deadline)) { + if (Date.now() >= Date.parse(consumed.expiresAt)) { this.removeConsumed(identity); } else { this.telemetry({ @@ -166,6 +186,22 @@ export class PendingApprovalsStore { return consumed.operationId; } } + const scope = this.operationScope(args); + if (args.reuseAfterConsume && this.persistence) { + const persisted = await this.persistence.findEvolutionOperation(scope); + if (persisted && Date.now() < Date.parse(persisted.expiresAt)) { + this.cacheConsumed(identity, persisted); + this.telemetry({ + event: "ask_approval_operation_recovered", + requestId: persisted.operationId, + toolName: args.toolName, + sessionId: args.sessionId, + authSub: args.authSub, + ts: new Date().toISOString(), + }); + return persisted.operationId; + } + } const requestId = this.identityIndex.get(identity); const record = requestId ? this.records.get(requestId) : undefined; if (!record || record.status !== "approved") return undefined; @@ -173,17 +209,35 @@ export class PendingApprovalsStore { this.expire(record.requestId); return undefined; } - this.remove(record); + let reusable: { operationId: string; expiresAt: string } | undefined; if (args.reuseAfterConsume) { - this.consumedByIdentity.set(identity, { + const fallback = { operationId: record.requestId, - deadline: record.deadline, - timer: setTimeout( - () => this.removeConsumed(identity), - Math.max(Date.parse(record.deadline) - Date.now(), 0), - ), - }); + expiresAt: new Date(Date.now() + EVOLUTION_OPERATION_RETENTION_MS).toISOString(), + }; + try { + const persisted = await this.persistence?.rememberEvolutionOperation({ + ...scope, + operationId: record.requestId, + }); + reusable = persisted + ? { operationId: record.requestId, expiresAt: persisted.expiresAt } + : fallback; + } catch (error) { + this.telemetry({ + event: "ask_approval_operation_persist_failed", + requestId: record.requestId, + toolName: record.toolName, + sessionId: record.sessionId, + authSub: record.authSub, + error: error instanceof Error ? error.message : String(error), + ts: new Date().toISOString(), + }); + return undefined; + } } + this.remove(record); + if (reusable) this.cacheConsumed(identity, reusable); this.telemetry({ event: "ask_approval_consumed", requestId: record.requestId, @@ -247,6 +301,27 @@ export class PendingApprovalsStore { this.consumedByIdentity.delete(identity); } + private cacheConsumed( + identity: string, + record: { operationId: string; expiresAt: string }, + ): void { + const timer = setTimeout( + () => this.removeConsumed(identity), + Math.max(Date.parse(record.expiresAt) - Date.now(), 0), + ); + timer.unref?.(); + this.consumedByIdentity.set(identity, { ...record, timer }); + } + + private operationScope(args: PendingConsumeArgs): EvolutionOperationScope { + return { + authSub: args.authSub, + sessionId: args.sessionId, + toolName: args.toolName, + inputDigest: approvalInputDigest(args.approvalInput), + }; + } + private identityFor(args: PendingConsumeArgs): string { return this.identityKey( args.authSub, @@ -296,4 +371,6 @@ function stableStringify(value: unknown): string { export const pendingApprovals = new PendingApprovalsStore(undefined, { insertPending, markResolved, + rememberEvolutionOperation, + findEvolutionOperation, }); diff --git a/packages/orchestration/src/permissions/repo.ts b/packages/orchestration/src/permissions/repo.ts index 47da24f5..53de7565 100644 --- a/packages/orchestration/src/permissions/repo.ts +++ b/packages/orchestration/src/permissions/repo.ts @@ -10,20 +10,24 @@ * * 何时用:仅 `PendingApprovalsStore`(写)与 permissions HTTP API(读 history)。 * - * 何时不用:审批**决策**永远走内存 Promise —— 本层是审计面不是闸门; - * 单测用 setPool() 注 mock,不连真实库。 + * 何时不用:普通 ask 审批的决策仍走内存;只有会产生成本的演化操作 + * 额外使用本层恢复稳定 operation ID。单测用 setPool() 注 mock。 * * 坑: * - * - **fail-open**:任何落库失败只 console.error,绝不阻断审批流(闸门语义 - * fail-closed 在 pending.ts,审计面挂了不能把审批一起拖死) + * - 审计写入仍 fail-open;演化 operation ledger 的 DB 错误会向上抛出,由 + * pending.ts fail-closed,避免重启/超时后丢失幂等边界。 * - DATABASE_URL 未配置时本层整体降级为 no-op(dev 无 PG 也能跑) */ import { Pool, type PoolConfig } from "pg"; import { getSettings } from "../config.js"; import { maskSensitive } from "../redact.js"; -import type { PendingApprovalView, PendingDecision } from "./pending.js"; +import type { + EvolutionOperationScope, + PendingApprovalView, + PendingDecision, +} from "./pending.js"; /** 终态:决策 / 超时 / 重启扫尾。 */ export type ApprovalStatus = @@ -135,6 +139,53 @@ export async function markResolved( } } +/** Persist one approved evolution identity before the costful tool is allowed to execute. */ +export async function rememberEvolutionOperation( + args: EvolutionOperationScope & { operationId: string }, +): Promise<{ expiresAt: string } | undefined> { + const pool = getPoolOrNull(); + if (!pool) return undefined; + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1_000); + const result = await pool.query( + `INSERT INTO evolution_approval_operations + (operation_id,auth_sub,session_id,tool_name,input_digest,approved_at,expires_at) + VALUES ($1,$2,$3,$4,$5,NOW(),$6) + ON CONFLICT (auth_sub,session_id,tool_name,input_digest) DO UPDATE SET + operation_id=EXCLUDED.operation_id, + approved_at=EXCLUDED.approved_at, + expires_at=EXCLUDED.expires_at + RETURNING expires_at`, + [ + args.operationId, + args.authSub, + args.sessionId, + args.toolName, + args.inputDigest, + expiresAt, + ], + ); + const row = result.rows[0]; + return row ? { expiresAt: toIso(row.expires_at) } : undefined; +} + +/** Recover an unexpired evolution operation after orchestration restart or transport timeout. */ +export async function findEvolutionOperation( + args: EvolutionOperationScope, +): Promise<{ operationId: string; expiresAt: string } | undefined> { + const pool = getPoolOrNull(); + if (!pool) return undefined; + const result = await pool.query( + `SELECT operation_id,expires_at FROM evolution_approval_operations + WHERE auth_sub=$1 AND session_id=$2 AND tool_name=$3 AND input_digest=$4 + AND expires_at>NOW()`, + [args.authSub, args.sessionId, args.toolName, args.inputDigest], + ); + const row = result.rows[0]; + return row + ? { operationId: String(row.operation_id), expiresAt: toIso(row.expires_at) } + : undefined; +} + /** * 启动扫尾:上一进程遗留的 pending 行批量置 expired_restart。 * 返回扫掉的行数(log / 测试用);DB 不可用返回 0。 diff --git a/packages/orchestration/src/tools/evolver-shared.ts b/packages/orchestration/src/tools/evolver-shared.ts index dd2218f2..14362a7f 100644 --- a/packages/orchestration/src/tools/evolver-shared.ts +++ b/packages/orchestration/src/tools/evolver-shared.ts @@ -1,8 +1,14 @@ /** Evolver Mastra tools 的共享 schema 与客户端解析。 */ import { z } from "zod"; -import { mintServiceToken, resolveRequestToken } from "../auth.js"; -import { EvolverClient } from "../clients/evolver.js"; +import { resolveRequestToken } from "../auth.js"; +import { + buildEvolutionStartRequest, + evolutionRequestDigest, + EvolverClient, + type EvolutionConfig, + type EvolutionStartRequest, +} from "../clients/evolver.js"; import { getSettings } from "../config.js"; import { AUTH_SUB_KEY } from "../hooks/with-hooks.js"; import { @@ -24,13 +30,14 @@ export async function getEvolverClient(ctx?: ToolRequestContext): Promise { const operationId = getRequestContextValue( { requestContext: ctx }, @@ -44,26 +51,19 @@ export async function getApprovedEvolutionRunContext( if (!operationId || !llmSnapshot || typeof authSub !== "string" || !authSub) { throw new Error("explicit evolution approval context is missing"); } - const approvalToken = await mintServiceToken( - { - sub: authSub, - token_use: "evolution_approval", - operation_id: operationId, - llm_config_digest: llmSnapshot.config_digest, - }, - 300, - ); + const request = buildEvolutionStartRequest({ ...input, llmSnapshot }); const credentialGrant = await mintEvolutionCredentialGrant({ authSub, operationId, + requestDigest: evolutionRequestDigest(request), snapshot: llmSnapshot, }); return { client: await getEvolverClient(ctx), operationId, - approvalToken, credentialGrant, llmSnapshot, + request, }; } diff --git a/packages/orchestration/src/tools/evolver.ts b/packages/orchestration/src/tools/evolver.ts index 1405783a..04744ded 100644 --- a/packages/orchestration/src/tools/evolver.ts +++ b/packages/orchestration/src/tools/evolver.ts @@ -24,16 +24,13 @@ export const evolverRunEvolutionTool = createTool({ }), execute: async (inputData, ctx) => { const approved = await getApprovedEvolutionRunContext( + inputData, ctx?.requestContext as ToolRequestContext | undefined, ); return await approved.client.startRun({ - budget: inputData.budget, - seedStrategyId: inputData.seedStrategyId, - config: inputData.config, + request: approved.request, idempotencyKey: approved.operationId, - approvalToken: approved.approvalToken, credentialGrant: approved.credentialGrant, - llmSnapshot: approved.llmSnapshot, }); }, }); diff --git a/packages/orchestration/tests/evolver-client.test.ts b/packages/orchestration/tests/evolver-client.test.ts index f0825ee9..4f1d04a5 100644 --- a/packages/orchestration/tests/evolver-client.test.ts +++ b/packages/orchestration/tests/evolver-client.test.ts @@ -3,8 +3,11 @@ import { generateKeyPairSync } from "node:crypto"; import { jwtVerify } from "jose"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { verifyToken } from "../src/auth.js"; -import { EvolverClient } from "../src/clients/evolver.js"; +import { + buildEvolutionStartRequest, + evolutionRequestDigest, + EvolverClient, +} from "../src/clients/evolver.js"; import { AUTH_SUB_KEY } from "../src/hooks/with-hooks.js"; import { APPROVAL_OPERATION_ID_KEY, @@ -32,9 +35,10 @@ function response(status: number): Response { } function options() { - return { + const request = buildEvolutionStartRequest({ budget: 1, seedStrategyId: "sma_cross_v1", + llmSnapshot: snapshot, config: { venue: "binance", symbol: "BTCUSDT", @@ -43,10 +47,11 @@ function options() { as_of: "2026-08-02T00:00:00Z", initial_cash: 10_000, }, + }); + return { + request, idempotencyKey: "approval-operation-1", - approvalToken: "approval-token", credentialGrant: "credential-grant", - llmSnapshot: snapshot, }; } @@ -56,7 +61,7 @@ afterEach(() => { }); describe("EvolverClient", () => { - it("mints a short-lived approval JWT bound to owner, operation, and snapshot", async () => { + it("mints one Ed25519 grant bound to owner, operation, snapshot, and request", async () => { const keys = generateKeyPairSync("ed25519"); vi.stubEnv( "EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64", @@ -68,27 +73,25 @@ describe("EvolverClient", () => { [USER_LLM_SNAPSHOT_KEY, snapshot], ]); - const approved = await getApprovedEvolutionRunContext(requestContext); - const payload = await verifyToken(approved.approvalToken); + const input = { + budget: 1, + seedStrategyId: "sma_cross_v1", + config: options().request.config, + }; + const approved = await getApprovedEvolutionRunContext(input, requestContext); const { payload: credential } = await jwtVerify( approved.credentialGrant, keys.publicKey, { algorithms: ["EdDSA"], audience: "inalpha-dashboard-credential" }, ); - expect(payload).toMatchObject({ - sub: "user:alice", - token_use: "evolution_approval", - operation_id: "approval-operation-1", - llm_config_digest: snapshot.config_digest, - }); - expect(Number(payload.exp) - Number(payload.iat)).toBe(300); expect(credential).toMatchObject({ sub: "user:alice", token_use: "evolution_credential", config_id: "config-1", operation_id: "approval-operation-1", llm_config_digest: snapshot.config_digest, + request_digest: evolutionRequestDigest(approved.request), }); expect(Number(credential.exp) - Number(credential.iat)).toBe(108_000); }); @@ -112,9 +115,7 @@ describe("EvolverClient", () => { expect((init.headers as Record)["Idempotency-Key"]).toBe( "approval-operation-1", ); - expect((init.headers as Record)["X-Evolution-Approval"]).toBe( - "approval-token", - ); + expect((init.headers as Record)["X-Evolution-Approval"]).toBeUndefined(); expect((init.headers as Record)["X-Evolution-Credential"]).toBe( "credential-grant", ); @@ -122,6 +123,30 @@ describe("EvolverClient", () => { } }); + it("normalizes defaults and timezone offsets before computing the request digest", () => { + const request = buildEvolutionStartRequest({ + config: { + venue: "binance", + symbol: "BTCUSDT", + timeframe: "1h", + from_ts: "2026-08-01T08:00:00+08:00", + as_of: "2026-08-02T08:00:00+08:00", + }, + llmSnapshot: snapshot, + }); + + expect(request.config).toMatchObject({ + from_ts: "2026-08-01T00:00:00.000Z", + as_of: "2026-08-02T00:00:00.000Z", + initial_cash: 10_000, + fee_rate: 0.001, + validation_split: 0.3, + }); + expect(evolutionRequestDigest(request)).toBe( + "0dd54caf902703e4303f252066bd9ad3ec1525c67cdab5bf1353befaefb48a2e", + ); + }); + it("retries a 502 once but does not retry other client errors", async () => { const retryable = vi .fn() diff --git a/packages/orchestration/tests/permissions-pending.test.ts b/packages/orchestration/tests/permissions-pending.test.ts index cc25c3c4..46613fa1 100644 --- a/packages/orchestration/tests/permissions-pending.test.ts +++ b/packages/orchestration/tests/permissions-pending.test.ts @@ -46,7 +46,7 @@ afterEach(() => { }); describe("PendingApprovalsStore", () => { - it("binds approval to owner, thread, tool and deterministic input digest", () => { + it("binds approval to owner, thread, tool and deterministic input digest", async () => { const store = new PendingApprovalsStore(() => {}); const view = request(store); expect(view.inputDigest).toBe(approvalInputDigest({ candidateId: "c-42" })); @@ -55,7 +55,7 @@ describe("PendingApprovalsStore", () => { expect(store.respond(view.requestId, "allow", "user:bob")).toBe(false); expect(store.respond(view.requestId, "allow", "user:alice")).toBe(true); expect( - store.consumeApproved({ + await store.consumeApproved({ authSub: "user:alice", sessionId: "thread-B", toolName: "paper.promote_candidate", @@ -63,7 +63,7 @@ describe("PendingApprovalsStore", () => { }), ).toBeUndefined(); expect( - store.consumeApproved({ + await store.consumeApproved({ authSub: "user:alice", sessionId: "thread-A", toolName: "paper.promote_candidate", @@ -71,7 +71,7 @@ describe("PendingApprovalsStore", () => { }), ).toBe(view.requestId); expect( - store.consumeApproved({ + await store.consumeApproved({ authSub: "user:alice", sessionId: "thread-A", toolName: "paper.promote_candidate", @@ -105,7 +105,7 @@ describe("PendingApprovalsStore", () => { store.clearAll(); }); - it("reuses one evolution operation ID for a same-scope transport retry", () => { + it("reuses one evolution operation ID for a same-scope transport retry", async () => { const store = new PendingApprovalsStore(() => {}); const args = { authSub: "user:alice", @@ -125,10 +125,10 @@ describe("PendingApprovalsStore", () => { approvalInput: args.approvalInput, reuseAfterConsume: true, }; - expect(store.consumeApproved(consume)).toBe(view.requestId); - expect(store.consumeApproved(consume)).toBe(view.requestId); + expect(await store.consumeApproved(consume)).toBe(view.requestId); + expect(await store.consumeApproved(consume)).toBe(view.requestId); expect( - store.consumeApproved({ + await store.consumeApproved({ ...consume, approvalInput: { request: { budget: 2 }, llm_snapshot: { config_digest: "digest" } }, }), @@ -136,7 +136,7 @@ describe("PendingApprovalsStore", () => { store.clearAll(); }); - it("does not reuse a consumed evolution operation after its deadline", () => { + it("does not reuse a consumed evolution operation after its retention deadline", async () => { vi.useFakeTimers(); const store = new PendingApprovalsStore(() => {}); const args = { @@ -156,13 +156,53 @@ describe("PendingApprovalsStore", () => { approvalInput: args.approvalInput, reuseAfterConsume: true, }; - expect(store.consumeApproved(consume)).toBe(view.requestId); + expect(await store.consumeApproved(consume)).toBe(view.requestId); - vi.advanceTimersByTime(51); + vi.advanceTimersByTime(24 * 60 * 60 * 1_000 + 1); - expect(store.consumeApproved(consume)).toBeUndefined(); + expect(await store.consumeApproved(consume)).toBeUndefined(); store.clearAll(); }); + + it("recovers a durable evolution operation in a fresh store", async () => { + const operations = new Map(); + const persistence = { + insertPending: vi.fn(async () => {}), + markResolved: vi.fn(async () => {}), + rememberEvolutionOperation: vi.fn(async (scope: { inputDigest: string; operationId: string }) => { + const value = { + operationId: scope.operationId, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }; + operations.set(scope.inputDigest, value); + return { expiresAt: value.expiresAt }; + }), + findEvolutionOperation: vi.fn(async (scope: { inputDigest: string }) => + operations.get(scope.inputDigest), + ), + }; + const args = { + authSub: "user:alice", + sessionId: "thread-A", + toolName: "evolver.run_evolution", + toolInput: { budget: 1 }, + approvalInput: { request: { budget: 1 }, llm_snapshot: { config_digest: "digest" } }, + timeoutMs: 5_000, + }; + const first = new PendingApprovalsStore(() => {}, persistence); + const view = first.request(args); + expect(first.respond(view.requestId, "allow", args.authSub)).toBe(true); + expect( + await first.consumeApproved({ ...args, reuseAfterConsume: true }), + ).toBe(view.requestId); + + const recovered = new PendingApprovalsStore(() => {}, persistence); + expect( + await recovered.consumeApproved({ ...args, reuseAfterConsume: true }), + ).toBe(view.requestId); + first.clearAll(); + recovered.clearAll(); + }); }); describe("permissions approval HTTP API", () => { @@ -202,7 +242,7 @@ describe("permissions approval HTTP API", () => { await respondRoute.handler(matching.ctx as never, async () => {}); expect(matching.captured.status).toBe(200); expect( - pendingApprovals.consumeApproved({ + await pendingApprovals.consumeApproved({ authSub: "user:alice", sessionId: "thread-A", toolName: "paper.promote_candidate", diff --git a/services/evolver/pyproject.toml b/services/evolver/pyproject.toml index 36c3ca08..ffeb859f 100644 --- a/services/evolver/pyproject.toml +++ b/services/evolver/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "pydantic>=2.13.0", "pydantic-settings>=2.14.0", "psycopg[binary,pool]>=3.3.0", + "cryptography>=46.0.0", "openai>=2.38.0", "unidiff>=0.7.0", ] diff --git a/services/evolver/src/inalpha_evolver/api/approval.py b/services/evolver/src/inalpha_evolver/api/approval.py index c94185be..c00cb394 100644 --- a/services/evolver/src/inalpha_evolver/api/approval.py +++ b/services/evolver/src/inalpha_evolver/api/approval.py @@ -1,13 +1,18 @@ -"""Mastra 显式审批断言验证。""" +"""Orchestration Ed25519 evolution capability verification.""" from __future__ import annotations +import base64 + import jwt +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from cryptography.hazmat.primitives.serialization import load_der_public_key from fastapi import HTTPException from ..config import EvolverSettings -_MAX_APPROVAL_TTL_SECONDS = 300 +_GRANT_AUDIENCE = "inalpha-evolver" +_MAX_GRANT_TTL_SECONDS = 30 * 60 * 60 def verify_evolution_approval( @@ -15,35 +20,53 @@ def verify_evolution_approval( *, owner_sub: str, operation_id: str, + config_id: str, + provider: str, llm_config_digest: str, + request_digest: str, settings: EvolverSettings, ) -> None: - """验证短效审批 JWT,并绑定 owner、幂等操作和 LLM 快照。""" + """Verify one owner/request-bound grant without giving Evolver signing authority.""" try: + encoded_key = settings.evolution_credential_public_key_b64.strip() + if not encoded_key: + raise HTTPException(status_code=503, detail="evolution grant verifier unavailable") + loaded_key = load_der_public_key(base64.b64decode(encoded_key, validate=True)) + if not isinstance(loaded_key, Ed25519PublicKey): + raise ValueError("evolution grant key must be Ed25519") + public_key = loaded_key payload = jwt.decode( token, - settings.jwt_secret, - algorithms=[settings.jwt_algorithm], - options={"require": ["sub", "exp", "iat"]}, + public_key, + algorithms=["EdDSA"], + audience=_GRANT_AUDIENCE, + options={"require": ["sub", "jti", "aud", "exp", "iat"]}, ) + except HTTPException: + raise + except (ValueError, TypeError) as exc: + raise HTTPException(status_code=503, detail="evolution grant verifier unavailable") from exc except jwt.PyJWTError as exc: - raise HTTPException(status_code=401, detail="invalid evolution approval") from exc + raise HTTPException(status_code=401, detail="invalid evolution grant") from exc expected = { - "token_use": "evolution_approval", + "token_use": "evolution_credential", "sub": owner_sub, "operation_id": operation_id, + "config_id": config_id, + "provider": provider, "llm_config_digest": llm_config_digest, + "request_digest": request_digest, } issued_at = payload.get("iat") expires_at = payload.get("exp") invalid_ttl = ( not isinstance(issued_at, int) or not isinstance(expires_at, int) - or expires_at - issued_at > _MAX_APPROVAL_TTL_SECONDS + or expires_at - issued_at > _MAX_GRANT_TTL_SECONDS or expires_at <= issued_at ) if invalid_ttl or any(payload.get(key) != value for key, value in expected.items()): - raise HTTPException(status_code=403, detail="evolution approval scope mismatch") + raise HTTPException(status_code=403, detail="evolution grant scope mismatch") __all__ = ["verify_evolution_approval"] diff --git a/services/evolver/src/inalpha_evolver/api/request_hash.py b/services/evolver/src/inalpha_evolver/api/request_hash.py index 325e93b9..7bbe40f2 100644 --- a/services/evolver/src/inalpha_evolver/api/request_hash.py +++ b/services/evolver/src/inalpha_evolver/api/request_hash.py @@ -3,10 +3,14 @@ import hashlib import json +import struct +from datetime import UTC, datetime from typing import Any from .schemas import StartRunRequest +_UNIX_EPOCH = datetime(1970, 1, 1, tzinfo=UTC) + def normalized_request(request: StartRunRequest) -> tuple[dict[str, Any], str]: payload = request.model_dump(mode="json") @@ -17,4 +21,38 @@ def normalized_request(request: StartRunRequest) -> tuple[dict[str, Any], str]: return config, digest -__all__ = ["normalized_request"] +def approval_request_digest(request: StartRunRequest) -> str: + """Compute the cross-language digest covered by the Ed25519 capability.""" + config = request.config + canonical = [ + request.seed_strategy_id, + _number_text(request.budget), + config.venue, + config.symbol, + config.timeframe, + _number_text(_epoch_milliseconds(config.from_ts)), + _number_text(_epoch_milliseconds(config.as_of)), + _float64_hex(config.initial_cash), + _float64_hex(config.fee_rate), + _float64_hex(config.validation_split), + request.llm.config_digest, + ] + return hashlib.sha256( + json.dumps(canonical, ensure_ascii=False, separators=(",", ":")).encode() + ).hexdigest() + + +def _number_text(value: int) -> str: + return str(value) + + +def _float64_hex(value: float) -> str: + return struct.pack(">d", value).hex() + + +def _epoch_milliseconds(value: datetime) -> int: + delta = value - _UNIX_EPOCH + return delta.days * 86_400_000 + delta.seconds * 1_000 + delta.microseconds // 1_000 + + +__all__ = ["approval_request_digest", "normalized_request"] diff --git a/services/evolver/src/inalpha_evolver/api/run_routes.py b/services/evolver/src/inalpha_evolver/api/run_routes.py index 9b658889..76cda8b7 100644 --- a/services/evolver/src/inalpha_evolver/api/run_routes.py +++ b/services/evolver/src/inalpha_evolver/api/run_routes.py @@ -17,7 +17,7 @@ from .approval import verify_evolution_approval from .cursor import decode_cursor, encode_cursor from .presenters import run_response -from .request_hash import normalized_request +from .request_hash import approval_request_digest, normalized_request from .schemas import RunListResponse, RunStatusResponse, StartRunRequest router = APIRouter() @@ -31,10 +31,6 @@ async def start_run( db: DBConn, user: Annotated[User, Depends(get_current_user)], idempotency_key: Annotated[str, Header(alias="Idempotency-Key", min_length=8, max_length=128)], - evolution_approval: Annotated[ - str, - Header(alias="X-Evolution-Approval", min_length=20, max_length=4096), - ], evolution_credential: Annotated[ str, Header(alias="X-Evolution-Credential", min_length=100, max_length=4096), @@ -42,14 +38,17 @@ async def start_run( ) -> RunStatusResponse: owner = account_id_from_user(user) settings = get_evolver_settings() + config, request_hash = normalized_request(body) verify_evolution_approval( - evolution_approval, + evolution_credential, owner_sub=user.user_id, operation_id=idempotency_key, + config_id=body.llm.config_id, + provider=body.llm.provider, llm_config_digest=body.llm.config_digest, + request_digest=approval_request_digest(body), settings=settings, ) - config, request_hash = normalized_request(body) async with db.transaction(): await db.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (str(owner),)) active = await run_queries.count_active(db, owner) diff --git a/services/evolver/src/inalpha_evolver/config.py b/services/evolver/src/inalpha_evolver/config.py index 4e881f5f..5d052461 100644 --- a/services/evolver/src/inalpha_evolver/config.py +++ b/services/evolver/src/inalpha_evolver/config.py @@ -62,7 +62,7 @@ class EvolverSettings(BaseSettings): alias="EVOLVER_QUEUE_TIMEOUT_S", ge=60, le=86400, - description="queued run 最长等待时间;必须短于凭据 grant 的 48 小时有效期。", + description="queued run 最长等待时间;必须短于凭据 grant 的 30 小时有效期。", ) evolver_account_active_limit: int = Field( default=2, @@ -87,6 +87,11 @@ class EvolverSettings(BaseSettings): jwt_secret: str = Field(default="dev-secret", alias="JWT_SECRET") jwt_algorithm: str = Field(default="HS256", alias="JWT_ALGORITHM") service_token_ttl_s: int = Field(default=3600, ge=60, le=86400) + evolution_credential_public_key_b64: str = Field( + default="", + alias="EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64", + description="Orchestration Ed25519 签名公钥(SPKI DER base64)。", + ) # ---- LLM ---- llm_api_key: str = Field( diff --git a/services/evolver/src/inalpha_evolver/storage/run_queries.py b/services/evolver/src/inalpha_evolver/storage/run_queries.py index d823afb6..e3a22b55 100644 --- a/services/evolver/src/inalpha_evolver/storage/run_queries.py +++ b/services/evolver/src/inalpha_evolver/storage/run_queries.py @@ -57,6 +57,7 @@ async def claim_next( ) await cur.execute( f"""WITH picked AS(SELECT run_id FROM strategy_evo_runs WHERE status='queued' +AND llm_snapshot_required AND llm_credential_grant_required ORDER BY queued_at FOR UPDATE SKIP LOCKED LIMIT 1)UPDATE strategy_evo_runs r SET status='running',active_stage='loading_data',started_at=%s,updated_at=%s FROM picked WHERE r.run_id=picked.run_id RETURNING {",".join(f"r.{name.strip()}" for name in _COLUMNS.split(","))}""", @@ -97,6 +98,13 @@ async def reconcile_interrupted(conn: AsyncConnection) -> int: """服务重启时保留 queued,收口可能已计费的 active run。""" now = datetime.now(UTC) async with conn.cursor() as cur: + await cur.execute( + """UPDATE strategy_evo_runs SET status='aborted',active_stage='aborted', +finished_at=%s,updated_at=%s,failure_code='EVOLUTION_UPGRADE_ABORTED', +failure_message='run queued by an image without owner LLM authorization metadata' +WHERE status='queued' AND (NOT llm_snapshot_required OR NOT llm_credential_grant_required)""", + (now, now), + ) await cur.execute( """UPDATE strategy_evo_candidates c SET stage='completed',outcome='cancelled', error_code='EVOLUTION_SERVICE_RESTARTED',error_message='service restarted during execution', diff --git a/services/evolver/src/inalpha_evolver/storage/runs.py b/services/evolver/src/inalpha_evolver/storage/runs.py index e38f9eab..a88cf8fe 100644 --- a/services/evolver/src/inalpha_evolver/storage/runs.py +++ b/services/evolver/src/inalpha_evolver/storage/runs.py @@ -35,9 +35,10 @@ async def insert_run( await cur.execute( f"""INSERT INTO strategy_evo_runs(run_id,owner_account_id,requested_by_sub, seed_strategy_id,budget,config,llm_snapshot,llm_config_digest,llm_credential_grant,status,idempotency_key, -request_hash,queued_at,venue,symbol,request_timeframe,data_timeframe,engine_timeframe, +request_hash,queued_at,llm_snapshot_required,llm_credential_grant_required, +venue,symbol,request_timeframe,data_timeframe,engine_timeframe, requested_as_of,seed_source_snapshot,seed_source_hash) VALUES -(%s,%s,%s,%s,%s,%s,%s,%s,%s,'queued',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) +(%s,%s,%s,%s,%s,%s,%s,%s,%s,'queued',%s,%s,%s,TRUE,TRUE,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT(owner_account_id,idempotency_key) DO NOTHING RETURNING {_COLUMNS},request_hash""", ( run_id, diff --git a/services/evolver/tests/llm_snapshot_fixtures.py b/services/evolver/tests/llm_snapshot_fixtures.py index 6b46c5b4..b1bbb832 100644 --- a/services/evolver/tests/llm_snapshot_fixtures.py +++ b/services/evolver/tests/llm_snapshot_fixtures.py @@ -2,10 +2,13 @@ from __future__ import annotations +import base64 import copy import time import jwt +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat VALID_LLM_SNAPSHOT = { "config_id": "config-1", @@ -24,6 +27,13 @@ "config_digest": "a4635b0c80f69b6054bdc2330b78cb98d9c81c849d476e7d01f1b8d626015c2c", } +EVOLUTION_GRANT_PRIVATE_KEY = Ed25519PrivateKey.generate() +EVOLUTION_GRANT_PUBLIC_KEY_B64 = base64.b64encode( + EVOLUTION_GRANT_PRIVATE_KEY.public_key().public_bytes( + Encoding.DER, PublicFormat.SubjectPublicKeyInfo + ) +).decode() + def llm_snapshot() -> dict: """返回可安全修改的有效快照副本。""" @@ -34,20 +44,25 @@ def approval_token( *, subject: str, operation_id: str, - secret: str, + request_digest: str, digest: str = VALID_LLM_SNAPSHOT["config_digest"], ) -> str: - """签发与 orchestration 相同 scope 的短效审批断言。""" + """签发与 orchestration 相同 scope 的 Ed25519 grant。""" now = int(time.time()) return jwt.encode( { "sub": subject, - "token_use": "evolution_approval", + "token_use": "evolution_credential", + "aud": ["inalpha-evolver", "inalpha-dashboard-credential"], + "jti": "11111111-1111-4111-8111-111111111111", "operation_id": operation_id, + "config_id": VALID_LLM_SNAPSHOT["config_id"], + "provider": VALID_LLM_SNAPSHOT["provider"], "llm_config_digest": digest, + "request_digest": request_digest, "iat": now, - "exp": now + 300, + "exp": now + 30 * 60 * 60, }, - secret, - algorithm="HS256", + EVOLUTION_GRANT_PRIVATE_KEY, + algorithm="EdDSA", ) diff --git a/services/evolver/tests/test_api_contract.py b/services/evolver/tests/test_api_contract.py index 396021ed..6ccb3e9f 100644 --- a/services/evolver/tests/test_api_contract.py +++ b/services/evolver/tests/test_api_contract.py @@ -9,7 +9,7 @@ from fastapi.testclient import TestClient from inalpha_evolver.api.presenters import candidate_response, run_response -from inalpha_evolver.api.request_hash import normalized_request +from inalpha_evolver.api.request_hash import approval_request_digest, normalized_request from inalpha_evolver.api.schemas import ( EvolutionConfig, EvolutionLLMSnapshot, @@ -45,6 +45,13 @@ def test_request_hash_is_stable_and_payload_sensitive() -> None: assert hash_a != hash_c +def test_approval_request_digest_matches_typescript_contract() -> None: + assert ( + approval_request_digest(_request()) + == "c3084e0f6daee93abc87dd3dc5804295e70649f33fcd2b3613cf6de71e876d88" + ) + + @pytest.mark.parametrize( ("path", "value"), [ diff --git a/services/evolver/tests/test_approval.py b/services/evolver/tests/test_approval.py index b15f3652..ca8ccb18 100644 --- a/services/evolver/tests/test_approval.py +++ b/services/evolver/tests/test_approval.py @@ -2,39 +2,51 @@ from __future__ import annotations +import base64 import time from types import SimpleNamespace import jwt import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat from fastapi import HTTPException from inalpha_evolver.api.approval import verify_evolution_approval -_SECRET = "approval-unit-test-secret-at-least-32-bytes" _DIGEST = "a" * 64 +_REQUEST_DIGEST = "b" * 64 +_PRIVATE_KEY = Ed25519PrivateKey.generate() +_PUBLIC_KEY_B64 = base64.b64encode( + _PRIVATE_KEY.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) +).decode() def _token( ttl_seconds: int, *, overrides: dict[str, object] | None = None, - secret: str = _SECRET, + private_key: Ed25519PrivateKey = _PRIVATE_KEY, ) -> str: now = int(time.time()) payload: dict[str, object] = { "sub": "user:alice", - "token_use": "evolution_approval", + "token_use": "evolution_credential", + "aud": ["inalpha-evolver", "inalpha-dashboard-credential"], + "jti": "11111111-1111-4111-8111-111111111111", "operation_id": "approval-operation-1", + "config_id": "config-1", + "provider": "deepseek", "llm_config_digest": _DIGEST, + "request_digest": _REQUEST_DIGEST, "iat": now, "exp": now + ttl_seconds, } payload.update(overrides or {}) return jwt.encode( payload, - secret, - algorithm="HS256", + private_key, + algorithm="EdDSA", ) @@ -43,16 +55,21 @@ def _verify(token: str) -> None: token, owner_sub="user:alice", operation_id="approval-operation-1", + config_id="config-1", + provider="deepseek", llm_config_digest=_DIGEST, - settings=SimpleNamespace(jwt_secret=_SECRET, jwt_algorithm="HS256"), # type: ignore[arg-type] + request_digest=_REQUEST_DIGEST, + settings=SimpleNamespace( # type: ignore[arg-type] + evolution_credential_public_key_b64=_PUBLIC_KEY_B64 + ), ) -def test_approval_accepts_only_short_lived_matching_scope() -> None: - _verify(_token(300)) +def test_approval_accepts_only_bounded_matching_scope() -> None: + _verify(_token(30 * 60 * 60)) with pytest.raises(HTTPException) as error: - _verify(_token(301)) + _verify(_token(30 * 60 * 60 + 1)) assert error.value.status_code == 403 @@ -61,14 +78,19 @@ def test_approval_rejects_another_owner() -> None: token = jwt.encode( { "sub": "user:bob", - "token_use": "evolution_approval", + "token_use": "evolution_credential", + "aud": ["inalpha-evolver", "inalpha-dashboard-credential"], + "jti": "11111111-1111-4111-8111-111111111111", "operation_id": "approval-operation-1", + "config_id": "config-1", + "provider": "deepseek", "llm_config_digest": _DIGEST, + "request_digest": _REQUEST_DIGEST, "iat": now, "exp": now + 300, }, - _SECRET, - algorithm="HS256", + _PRIVATE_KEY, + algorithm="EdDSA", ) with pytest.raises(HTTPException) as error: @@ -81,7 +103,10 @@ def test_approval_rejects_another_owner() -> None: [ {"token_use": "session"}, {"operation_id": "another-operation"}, + {"config_id": "config-2"}, + {"provider": "openai"}, {"llm_config_digest": "b" * 64}, + {"request_digest": "c" * 64}, {"iat": None}, {"exp": None}, ], @@ -95,7 +120,7 @@ def test_approval_rejects_invalid_claims(overrides: dict[str, object]) -> None: def test_approval_rejects_expired_bad_signature_and_non_positive_ttl() -> None: tokens = [ _token(-1), - _token(300, secret="different-secret-at-least-32-bytes"), + _token(300, private_key=Ed25519PrivateKey.generate()), _token(300, overrides={"iat": int(time.time()) + 300}), ] for token in tokens: diff --git a/services/evolver/tests/test_e2e.py b/services/evolver/tests/test_e2e.py index 13541e27..42a1102c 100644 --- a/services/evolver/tests/test_e2e.py +++ b/services/evolver/tests/test_e2e.py @@ -11,15 +11,26 @@ import pytest from fastapi.testclient import TestClient +from inalpha_evolver.api.request_hash import approval_request_digest +from inalpha_evolver.api.schemas import StartRunRequest from inalpha_evolver.config import get_evolver_settings from inalpha_evolver.main import app -from .llm_snapshot_fixtures import approval_token, llm_snapshot +from .llm_snapshot_fixtures import ( + EVOLUTION_GRANT_PUBLIC_KEY_B64, + approval_token, + llm_snapshot, +) _SECRET = "evolver-test-secret-at-least-32-bytes-long" -def _headers(key: str | None = None, *, include_approval: bool = True) -> dict[str, str]: +def _headers( + payload: dict | None = None, + key: str | None = None, + *, + include_grant: bool = True, +) -> dict[str, str]: subject = f"test:{uuid4()}" token = jwt.encode( {"sub": subject, "exp": int(time.time()) + 3600}, @@ -29,12 +40,12 @@ def _headers(key: str | None = None, *, include_approval: bool = True) -> dict[s headers = {"Authorization": f"Bearer {token}"} if key: headers["Idempotency-Key"] = key - headers["X-Evolution-Credential"] = "signed-grant-" + "x" * 120 - if include_approval: - headers["X-Evolution-Approval"] = approval_token( + if include_grant and payload is not None: + request = StartRunRequest.model_validate(payload) + headers["X-Evolution-Credential"] = approval_token( subject=subject, operation_id=key, - secret=_SECRET, + request_digest=approval_request_digest(request), ) return headers @@ -46,6 +57,7 @@ def client() -> TestClient: "postgresql+psycopg://quant:devpass@localhost:5433/inalpha_evo_test", ) os.environ["JWT_SECRET"] = _SECRET + os.environ["EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64"] = EVOLUTION_GRANT_PUBLIC_KEY_B64 get_evolver_settings.cache_clear() with TestClient(app) as value: yield value @@ -74,38 +86,52 @@ def test_business_endpoints_require_auth(client: TestClient) -> None: def test_start_requires_idempotency_key(client: TestClient) -> None: - assert client.post("/api/v1/runs", json=_payload(), headers=_headers()).status_code == 400 + payload = _payload() + assert client.post("/api/v1/runs", json=payload, headers=_headers(payload)).status_code == 400 def test_start_requires_explicit_approval_assertion(client: TestClient) -> None: - headers = _headers(f"api-test-{uuid4()}", include_approval=False) - assert client.post("/api/v1/runs", json=_payload(), headers=headers).status_code == 400 + payload = _payload() + headers = _headers(payload, f"api-test-{uuid4()}", include_grant=False) + assert client.post("/api/v1/runs", json=payload, headers=headers).status_code == 400 def test_start_rejects_approval_for_another_operation(client: TestClient) -> None: key = f"api-test-{uuid4()}" - headers = _headers(key) - token = headers["X-Evolution-Approval"] - payload = jwt.decode(token, _SECRET, algorithms=["HS256"]) - headers["X-Evolution-Approval"] = approval_token( - subject=payload["sub"], + body = _payload() + headers = _headers(body, key) + token_payload = jwt.decode( + headers["X-Evolution-Credential"], options={"verify_signature": False} + ) + headers["X-Evolution-Credential"] = approval_token( + subject=token_payload["sub"], operation_id=f"other-{uuid4()}", - secret=_SECRET, + request_digest=approval_request_digest(StartRunRequest.model_validate(body)), ) - assert client.post("/api/v1/runs", json=_payload(), headers=headers).status_code == 403 + assert client.post("/api/v1/runs", json=body, headers=headers).status_code == 403 + + +def test_start_rejects_business_request_tampered_after_approval(client: TestClient) -> None: + key = f"api-test-{uuid4()}" + payload = _payload() + headers = _headers(payload, key) + payload["budget"] = 2 + + assert client.post("/api/v1/runs", json=payload, headers=headers).status_code == 403 def test_start_rejects_tampered_snapshot_before_approval(client: TestClient) -> None: key = f"api-test-{uuid4()}" payload = _payload() + headers = _headers(payload, key) payload["llm"]["model"] = "tampered-model" - assert client.post("/api/v1/runs", json=payload, headers=_headers(key)).status_code == 400 + assert client.post("/api/v1/runs", json=payload, headers=headers).status_code == 400 def test_start_returns_queued_and_is_idempotent(client: TestClient) -> None: key = f"api-test-{uuid4()}" - headers = _headers(key) payload = _payload() + headers = _headers(payload, key) first = client.post("/api/v1/runs", json=payload, headers=headers) second = client.post("/api/v1/runs", json=payload, headers=headers) diff --git a/services/evolver/uv.lock b/services/evolver/uv.lock index 8ef26925..04df96aa 100644 --- a/services/evolver/uv.lock +++ b/services/evolver/uv.lock @@ -329,6 +329,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/19/0b6647bf5e331521e55d2b63bfbdc210bd9cd605189273f03614a05f702d/colorlog-6.12.0-py3-none-any.whl", hash = "sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e", size = 12239, upload-time = "2026-07-23T13:40:39.562Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -590,6 +640,7 @@ name = "inalpha-evolver" version = "0.2.0" source = { editable = "." } dependencies = [ + { name = "cryptography" }, { name = "fastapi" }, { name = "inalpha-paper" }, { name = "inalpha-shared" }, @@ -613,6 +664,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "cryptography", specifier = ">=46.0.0" }, { name = "fastapi", specifier = ">=0.136.0" }, { name = "inalpha-paper", editable = "../paper" }, { name = "inalpha-shared", editable = "../_shared" },