From 8f6721cec3fd207339cb7fee2b8dabf42f233cd1 Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 23 Jul 2026 13:50:48 +0800 Subject: [PATCH 01/11] =?UTF-8?q?fix(factor):=20=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E4=BF=A1=20P0=20=E5=9B=9E=E6=B5=8B=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除因子评估对已知错误回测器的调用,并注销对应 HTTP 路由,避免输出不可审计的金融结论。 Co-Authored-By: Claude Sonnet 4.6 --- packages/orchestration/src/clients/factor.ts | 59 ------------------- packages/orchestration/src/tools/factor.ts | 16 ----- .../tests/factor-discovery.test.ts | 38 +++++++++++- services/factor/src/inalpha_factor/main.py | 2 - services/factor/tests/test_api.py | 5 ++ 5 files changed, 42 insertions(+), 78 deletions(-) diff --git a/packages/orchestration/src/clients/factor.ts b/packages/orchestration/src/clients/factor.ts index 964d4e7f..c8c4bed6 100644 --- a/packages/orchestration/src/clients/factor.ts +++ b/packages/orchestration/src/clients/factor.ts @@ -161,35 +161,6 @@ export type ProposeFactorResult = { status: string; }; -export type BacktestScoreResult = { - venue: string; - symbol: string; - timeframe: string; - as_of: string; - horizon_bars: number; - bars_used: number; - available: boolean; - reason: string | null; - expression: string; - factor: FactorEffectiveness | null; - ic_pvalue: number | null; - top_correlated: { factor_id: string; corr: number }[]; - max_corr: number | null; - is_likely_redundant: boolean; - backtest: { - oos_sharpe: number | null; - oos_sharpe_p5: number | null; - oos_sharpe_p95: number | null; - oos_max_drawdown_pct: number | null; - oos_win_rate: number | null; - oos_return_pct: number | null; - baseline_sharpe: number | null; - dsr: number | null; - n_paths: number; - splitter_used: string; - } | null; -}; - export class FactorClient { private readonly http: HttpClient; @@ -232,34 +203,4 @@ export class FactorClient { async listCandidates(params: Record = {}): Promise { return await this.http.get("/candidates", params as Record); } - - async backtestScore(params: { - expression: string; - name?: string; - venue: string; - symbol: string; - timeframe: string; - asOf?: string; - lookbackBars?: number; - horizonBars?: number; - initialCash?: number; - feeRate?: number; - cvSplitter?: string; - cvNFolds?: number; - }): Promise { - return await this.http.post("/backtest/score", { - expression: params.expression, - name: params.name, - venue: params.venue, - symbol: params.symbol, - timeframe: params.timeframe, - as_of: params.asOf, - lookback_bars: params.lookbackBars, - horizon_bars: params.horizonBars, - initial_cash: params.initialCash, - fee_rate: params.feeRate, - cv_splitter: params.cvSplitter, - cv_n_folds: params.cvNFolds, - }); - } } \ No newline at end of file diff --git a/packages/orchestration/src/tools/factor.ts b/packages/orchestration/src/tools/factor.ts index 892a45d2..8b9de524 100644 --- a/packages/orchestration/src/tools/factor.ts +++ b/packages/orchestration/src/tools/factor.ts @@ -395,19 +395,6 @@ export const factorEvaluateCandidateTool = createTool({ horizonBars: inputData.horizonBars, }); - // P0: 因子评估后自动跑 WalkForward 回测闭环 - const btResult = await client.backtestScore({ - expression: inputData.expression, - name: inputData.name, - venue: inputData.venue, - symbol: inputData.symbol ?? "", - timeframe: inputData.timeframe ?? "1h", - asOf: inputData.asOf, - lookbackBars: inputData.lookbackBars, - horizonBars: inputData.horizonBars, - }).catch(() => null); - - // P3: 判断演化潜力——IC 有潜力但未过门限时标注 const evolutionPotential: { suggest: boolean; reason: string | null; @@ -415,8 +402,6 @@ export const factorEvaluateCandidateTool = createTool({ if (result.available && result.factor && result.ic_pvalue != null) { const ic = Math.abs(result.factor.rank_ic); const pval = result.ic_pvalue; - // 条件:IC 在 0.02-0.06 之间(有潜力但不够强),且 p < 0.2(不是纯噪声), - // 且不是 redundant(换了也白换) if (ic >= 0.02 && ic < 0.06 && pval < 0.2 && !result.is_likely_redundant) { evolutionPotential.suggest = true; evolutionPotential.reason = @@ -427,7 +412,6 @@ export const factorEvaluateCandidateTool = createTool({ return { ...result, - backtest: btResult?.backtest ?? null, evolution_potential: evolutionPotential, }; }, diff --git a/packages/orchestration/tests/factor-discovery.test.ts b/packages/orchestration/tests/factor-discovery.test.ts index eadefe74..76cd2daa 100644 --- a/packages/orchestration/tests/factor-discovery.test.ts +++ b/packages/orchestration/tests/factor-discovery.test.ts @@ -46,7 +46,7 @@ describe("factor.evaluate_candidate", () => { vi.stubGlobal( "fetch", vi.fn(async (url: string, init?: RequestInit) => { - // 只捕获 /custom/score 的请求体(P0 回测也会发请求,别覆盖掉 customScore 的记录) + // 只捕获 /custom/score 的请求体。 if (url.includes("/custom/score")) { capturedUrl = url; capturedBody = (init?.body as string) ?? ""; @@ -92,6 +92,42 @@ describe("factor.evaluate_candidate", () => { expect(body.horizonBars).toBe(5); expect((result as { ic_pvalue: number }).ic_pvalue).toBe(0.03); }); + it("does not call the retired P0 backtest endpoint or return its result", async () => { + const urls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + urls.push(url); + return new Response( + JSON.stringify({ + venue: "binance", + symbol: "BTC/USDT", + timeframe: "1h", + as_of: "2026-06-11T00:00:00Z", + horizon_bars: 5, + bars_used: 720, + available: true, + reason: null, + expression: "Mean($close, 20)", + factor: null, + ic_pvalue: null, + top_correlated: [], + max_corr: null, + is_likely_redundant: false, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }), + ); + + const result = await factorEvaluateCandidateTool.execute!( + { expression: "Mean($close, 20)", venue: "binance", symbol: "BTC/USDT" } as never, + ctx(), + ); + + expect(urls).toEqual(["http://factor-mock.test/custom/score"]); + expect(result).not.toHaveProperty("backtest"); + }); }); describe("factor.propose / factor.list_candidates", () => { diff --git a/services/factor/src/inalpha_factor/main.py b/services/factor/src/inalpha_factor/main.py index afb229a2..49d60dd1 100644 --- a/services/factor/src/inalpha_factor/main.py +++ b/services/factor/src/inalpha_factor/main.py @@ -23,7 +23,6 @@ from . import __version__, custom_registry from .api import ( - backtest_score, candidates, catalog, compute, @@ -96,5 +95,4 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.include_router(snapshot.router) app.include_router(panel.router) app.include_router(custom.router) -app.include_router(backtest_score.router) app.include_router(candidates.router) diff --git a/services/factor/tests/test_api.py b/services/factor/tests/test_api.py index 61ae6b2c..315bfe83 100644 --- a/services/factor/tests/test_api.py +++ b/services/factor/tests/test_api.py @@ -41,6 +41,11 @@ def test_health(client: TestClient) -> None: assert body["adapters"]["qlib_alpha158"] is True +def test_retired_backtest_score_route_is_unreachable(client: TestClient) -> None: + response = client.post("/backtest/score", json={}) + assert response.status_code == 404 + + def test_catalog(client: TestClient) -> None: r = client.get("/catalog") assert r.status_code == 200 From ff6b960a51ff720d1ce7f463ee563b1ef5f00232 Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 23 Jul 2026 13:51:00 +0800 Subject: [PATCH 02/11] =?UTF-8?q?feat(paper):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E4=B8=80=E8=87=B4=E6=80=A7=E8=AF=84=E4=BC=B0?= =?UTF-8?q?=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用 paper 回测内核输出固定 180 天的四态一致性证据,不改变通用回测契约或默认窗口。 Co-Authored-By: Claude Sonnet 4.6 --- packages/orchestration/src/tools/index.ts | 3 + packages/orchestration/src/tools/paper.ts | 108 ++++++++++++++++++++- packages/orchestration/tests/tools.test.ts | 85 ++++++++++++++++ 3 files changed, 195 insertions(+), 1 deletion(-) diff --git a/packages/orchestration/src/tools/index.ts b/packages/orchestration/src/tools/index.ts index f1f5f2ed..0cc8dbd2 100644 --- a/packages/orchestration/src/tools/index.ts +++ b/packages/orchestration/src/tools/index.ts @@ -17,6 +17,7 @@ import { dataTools, } from "./data.js"; import { + paperAssessWindowConsistencyTool, paperCheckSensitivityTool, paperComposeStrategyTool, paperCvBacktestTool, @@ -128,6 +129,7 @@ export { factorTimingTool, getTradePlanTool, paperAuthorStrategyTool, + paperAssessWindowConsistencyTool, paperCheckSensitivityTool, paperComposeStrategyTool, paperCvBacktestTool, @@ -255,6 +257,7 @@ export const orchestratorToolList = [ paperListStrategiesTool, // ADR-0051:策略原型库——写策略前按因子 kind 取骨架当起点 paperListArchetypesTool, + paperAssessWindowConsistencyTool, paperRunBacktestTool, // D-12 · 参数邻域敏感性(promote 前必跑,cliff = 过拟合信号) paperCheckSensitivityTool, diff --git a/packages/orchestration/src/tools/paper.ts b/packages/orchestration/src/tools/paper.ts index 17eecdf0..62cb59f6 100644 --- a/packages/orchestration/src/tools/paper.ts +++ b/packages/orchestration/src/tools/paper.ts @@ -5,7 +5,7 @@ import { createTool } from "@mastra/core/tools"; import { z } from "zod"; import { resolveRequestToken } from "../auth.js"; -import { PaperClient } from "../clients/paper.js"; +import { PaperClient, type ValidationBlock } from "../clients/paper.js"; import { getSettings } from "../config.js"; // D-9 multi-market:与 tools/data.ts 保持一致——5 种 venue 全覆盖。 @@ -140,6 +140,111 @@ export const paperListArchetypesTool = createTool({ }, }); +// ──────────────────────────────────────────────────────────────────── +// paper.assess_window_consistency +// ──────────────────────────────────────────────────────────────────── + +type WindowConsistencyStatus = + | "insufficient_data" + | "invalid_baseline" + | "decaying" + | "stable"; + +function isFiniteNumber(value: number | null | undefined): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function assessWindowConsistency(validation: ValidationBlock | null | undefined): WindowConsistencyStatus { + if ( + !validation || + validation.flags.includes("insufficient_sample") || + validation.flags.includes("sharpe_undefined") || + !isFiniteNumber(validation.train.sharpe) || + !isFiniteNumber(validation.holdout.sharpe) + ) { + return "insufficient_data"; + } + if (validation.flags.includes("train_sharpe_nonpositive") || validation.train.sharpe <= 0) { + return "invalid_baseline"; + } + if (!isFiniteNumber(validation.decay_ratio)) { + return "insufficient_data"; + } + if (validation.decay_ratio < 0.5 || validation.holdout.sharpe < 0) { + return "decaying"; + } + return "stable"; +} + +export const paperAssessWindowConsistencyTool = createTool({ + id: "paper.assess_window_consistency", + description: ` + 对固定的 180 天回测窗口做一致性检查,输出数据是否不足、基线是否有效、是否衰减或稳定。 + + 何时用: + - 首次验证固定黄金路径的回测证据 + - 需要明确说明 train/holdout 窗口内一致性,而非声称盲 OOS + + 何时不用: + - 想自定义任意回测区间或参数探索 → paper.run_backtest + - 需要 purged CV、CPCV 或 DSR → paper.cv_backtest + + 坑: + - 这是 70/30 的 window_consistency,不是盲 OOS + - cv 固定 disabled;CI 跨 0仅是 evidence,不改变状态 + - run_id 缺失代表结果没有持久化,直接失败,不能作为可追溯证据 + `.trim(), + inputSchema: z.object({ + strategyId: z.string().default("sma_cross").describe("已注册策略;黄金路径默认 sma_cross"), + params: z.record(z.string(), z.unknown()).default({}).describe("策略参数"), + asOf: z.string().datetime({ offset: true }).optional().describe("窗口结束;缺省为当前时刻"), + }), + execute: async (inputData, ctx) => { + const tc = ctx?.requestContext as ToolRequestContext | undefined; + const client = await getBacktestClient(tc); + const toTs = inputData.asOf ?? new Date().toISOString(); + const fromTs = new Date(new Date(toTs).getTime() - 180 * 24 * 3600 * 1000).toISOString(); + const report = await client.runBacktest({ + strategyId: inputData.strategyId, + params: inputData.params, + venue: "binance", + symbol: "BTC/USDT", + timeframe: "1h", + fromTs, + toTs, + initialCash: 10_000, + feeRate: 0.001, + tradingMode: "spot", + }); + if (!report.run_id) { + throw new Error("paper.assess_window_consistency: backtest run was not persisted"); + } + const validation = report.validation ?? null; + return { + status: assessWindowConsistency(validation), + evidence: { + trainSharpe: validation?.train.sharpe ?? null, + holdoutSharpe: validation?.holdout.sharpe ?? null, + decayRatio: validation?.decay_ratio ?? null, + holdoutSharpeCiIncludesZero: validation?.holdout_sharpe_ci_includes_zero ?? null, + flags: validation?.flags ?? [], + }, + rawValidation: validation, + backtestRunId: report.run_id, + venue: report.venue, + symbol: report.symbol, + timeframe: report.timeframe, + periodStart: report.period_start, + periodEnd: report.period_end, + dataAsOf: report.period_end, + feeRate: 0.001, + totalFees: report.total_fees, + validationKind: "window_consistency" as const, + cv: "disabled" as const, + }; + }, +}); + // ──────────────────────────────────────────────────────────────────── // paper.run_backtest // ──────────────────────────────────────────────────────────────────── @@ -1046,6 +1151,7 @@ export const paperListStrategyRunDecisionsTool = createTool({ export const paperTools = [ paperListStrategiesTool, + paperAssessWindowConsistencyTool, paperRunBacktestTool, paperCheckSensitivityTool, paperCvBacktestTool, diff --git a/packages/orchestration/tests/tools.test.ts b/packages/orchestration/tests/tools.test.ts index 030654d8..a3e3b7ed 100644 --- a/packages/orchestration/tests/tools.test.ts +++ b/packages/orchestration/tests/tools.test.ts @@ -14,6 +14,7 @@ import { factorPanelScoreTool, factorScoreTool, factorTimingTool, + paperAssessWindowConsistencyTool, paperListStrategiesTool, paperListStrategyRunDecisionsTool, paperListStrategyRunsTool, @@ -255,6 +256,90 @@ describe("paper.list_strategies", () => { }); }); +// ──────────────────────────────────────────────────────────────────── +// paper.assess_window_consistency +// ──────────────────────────────────────────────────────────────────── + +function backtestReport(validation: unknown, runId = "550e8400-e29b-41d4-a716-446655440000") { + return { + run_id: runId, + venue: "binance", + symbol: "BTC/USDT", + timeframe: "1h", + period_start: "2026-01-01T00:00:00Z", + period_end: "2026-06-30T00:00:00Z", + total_fees: 12.5, + validation, + }; +} + +describe("paper.assess_window_consistency", () => { + it.each([ + ["insufficient_data", null], + ["insufficient_data", { train: { sharpe: null }, holdout: { sharpe: 1 }, decay_ratio: null, holdout_sharpe_ci_includes_zero: null, flags: ["sharpe_undefined"] }], + ["insufficient_data", { train: { sharpe: 2 }, holdout: { sharpe: 1 }, decay_ratio: null, holdout_sharpe_ci_includes_zero: null, flags: [] }], + ["invalid_baseline", { train: { sharpe: 0 }, holdout: { sharpe: 1 }, decay_ratio: null, holdout_sharpe_ci_includes_zero: false, flags: ["train_sharpe_nonpositive"] }], + ["decaying", { train: { sharpe: 2 }, holdout: { sharpe: 0.9 }, decay_ratio: 0.45, holdout_sharpe_ci_includes_zero: false, flags: [] }], + ["stable", { train: { sharpe: 2 }, holdout: { sharpe: 1.2 }, decay_ratio: 0.6, holdout_sharpe_ci_includes_zero: true, flags: [] }], + ])("maps validation to %s", async (status, validation) => { + mockFetch(async () => + new Response(JSON.stringify(backtestReport(validation)), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const result = await paperAssessWindowConsistencyTool.execute!( + { asOf: "2026-06-30T00:00:00Z" } as never, + ctx(), + ); + + expect((result as { status: string }).status).toBe(status); + expect((result as { validationKind: string }).validationKind).toBe("window_consistency"); + expect((result as { cv: string }).cv).toBe("disabled"); + }); + + it("uses the fixed 180-day Binance spot contract", async () => { + let body = ""; + mockFetch(async (_url, init) => { + body = String(init?.body); + return new Response(JSON.stringify(backtestReport(null)), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await paperAssessWindowConsistencyTool.execute!( + { asOf: "2026-06-30T00:00:00Z" } as never, + ctx(), + ); + + expect(JSON.parse(body)).toMatchObject({ + strategy_id: "sma_cross", + venue: "binance", + symbol: "BTC/USDT", + timeframe: "1h", + trading_mode: "spot", + fee_rate: 0.001, + from_ts: "2026-01-01T00:00:00.000Z", + to_ts: "2026-06-30T00:00:00Z", + }); + }); + + it("fails when paper did not persist the run", async () => { + mockFetch(async () => + new Response(JSON.stringify(backtestReport(null, null as never)), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect( + paperAssessWindowConsistencyTool.execute!({} as never, ctx()), + ).rejects.toThrow("backtest run was not persisted"); + }); +}); + // ──────────────────────────────────────────────────────────────────── // paper.run_backtest // ──────────────────────────────────────────────────────────────────── From 0f59c29ac82e2c1a9a5734e06a2575c85c24a2e2 Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 23 Jul 2026 13:51:13 +0800 Subject: [PATCH 03/11] =?UTF-8?q?fix(orchestration):=20=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E7=94=A8=E6=88=B7=20LLM=20=E5=AF=86=E9=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 身份中间件只记录已筛选的配置元数据,移除原始请求头日志并用回归测试保护密钥边界。 Co-Authored-By: Claude Sonnet 4.6 --- packages/orchestration/src/mastra/identity.ts | 78 +++++++++++++++++ packages/orchestration/src/mastra/index.ts | 84 +------------------ packages/orchestration/tests/identity.test.ts | 40 +++++++++ 3 files changed, 119 insertions(+), 83 deletions(-) create mode 100644 packages/orchestration/src/mastra/identity.ts create mode 100644 packages/orchestration/tests/identity.test.ts diff --git a/packages/orchestration/src/mastra/identity.ts b/packages/orchestration/src/mastra/identity.ts new file mode 100644 index 00000000..b6e7256a --- /dev/null +++ b/packages/orchestration/src/mastra/identity.ts @@ -0,0 +1,78 @@ +import type { MiddlewareHandler } from "hono"; + +import { verifyToken } from "../auth.js"; +import { AUTH_SUB_KEY } from "../hooks/with-hooks.js"; +import { userLLMStore, type UserLLMConfig } from "./llm/provider.js"; + +let warnedNoRequestContext = false; +let warnedAuthSignature = false; + +/** Parses a user-owned LLM configuration without logging the raw header. */ +export function parseUserLLMConfigHeader(raw: string | undefined): UserLLMConfig | undefined { + if (!raw?.trim()) return undefined; + const parsed = JSON.parse(raw) as Record; + if ( + typeof parsed.provider !== "string" || + typeof parsed.api_key !== "string" || + parsed.api_key.trim() === "" + ) { + return undefined; + } + return { + id: typeof parsed.id === "string" ? parsed.id : "req", + provider: parsed.provider as UserLLMConfig["provider"], + model: typeof parsed.model === "string" ? parsed.model : undefined, + api_key: parsed.api_key, + custom_base_url: typeof parsed.custom_base_url === "string" ? parsed.custom_base_url : undefined, + custom_provider_name: + typeof parsed.custom_provider_name === "string" ? parsed.custom_provider_name : undefined, + label: typeof parsed.label === "string" ? parsed.label : undefined, + }; +} + +/** Injects authenticated identity and the user-owned LLM configuration into request scope. */ +export const identityMiddleware: MiddlewareHandler = async (c, next) => { + let userConfig: UserLLMConfig | undefined; + try { + userConfig = parseUserLLMConfigHeader(c.req.header("X-LLM-Config")); + if (userConfig) { + console.log("[identity-mw] Parsed userConfig:", { + id: userConfig.id, + provider: userConfig.provider, + model: userConfig.model, + }); + } + } catch { + // Invalid user configuration falls back to the configured model path. + } + + try { + const authz = c.req.header("Authorization"); + const token = authz?.startsWith("Bearer ") ? authz.slice(7).trim() : undefined; + if (token) { + const payload = await verifyToken(token); + const sub = typeof payload.sub === "string" && payload.sub ? payload.sub : undefined; + if (sub) { + const requestContext = c.get("requestContext") as { set?: (key: string, value: unknown) => void } | undefined; + if (typeof requestContext?.set === "function") { + requestContext.set(AUTH_SUB_KEY, sub); + } else if (!warnedNoRequestContext) { + warnedNoRequestContext = true; + console.warn("[identity-mw] requestContext unavailable; authenticated scope was not injected"); + } + } + } + } catch (error) { + const code = (error as { code?: unknown } | null)?.code; + if (code === "ERR_JWS_SIGNATURE_VERIFICATION_FAILED" && !warnedAuthSignature) { + warnedAuthSignature = true; + console.warn("[identity-mw] Bearer signature verification failed; check JWT_SECRET"); + } + } + + if (userConfig) { + await userLLMStore.run(userConfig, next); + } else { + await next(); + } +}; diff --git a/packages/orchestration/src/mastra/index.ts b/packages/orchestration/src/mastra/index.ts index 89e9cf3c..a232e7f2 100644 --- a/packages/orchestration/src/mastra/index.ts +++ b/packages/orchestration/src/mastra/index.ts @@ -16,6 +16,7 @@ import { resolve } from "node:path"; import { loadEnvFile } from "node:process"; import { resolveMastraDbDir, resolveOrchestrationRoot } from "./paths.js"; +import { identityMiddleware } from "./identity.js"; // dev 启动时显式加载 package 根 .env。注意不能按 cwd 解析:mastra dev 的 server // 子进程 cwd 是 src/mastra/public/(此前靠 CLI 父进程 env 继承碰巧生效)。 @@ -31,7 +32,6 @@ if (existsSync(rootEnvPath)) { import { Mastra } from "@mastra/core/mastra"; import { LibSQLStore } from "@mastra/libsql"; -import type { MiddlewareHandler } from "hono"; import { PinoLogger } from "@mastra/loggers"; import { ConsoleExporter, @@ -40,10 +40,7 @@ import { SamplingStrategyType, } from "@mastra/observability"; -import { verifyToken } from "../auth.js"; import { getSettings } from "../config.js"; -import { AUTH_SUB_KEY } from "../hooks/with-hooks.js"; -import { userLLMStore, type UserLLMConfig } from "./llm/provider.js"; import { divinationApiRoutes } from "../divination/api.js"; import { closePool as closeDivinationPool } from "../divination/repo.js"; import { permissionsApiRoutes } from "../permissions/api.js"; @@ -95,85 +92,6 @@ const observability = new Observability({ * - 多租户:dashboard 给每用户发各自 JWT → 自动按用户隔离,无需再改 askCache。 * - 无 / 非法 / 过期 token:不注入,沿用既有 fallback,绝不阻断请求(审批门有后端硬校验兜底)。 */ -/** 进程内仅 warn 一次 requestContext 缺失 / Bearer 签名失败(避免每请求刷屏),见 identityMiddleware。 */ -let _warnedNoRequestContext = false; -let _warnedAuthSignature = false; - -const identityMiddleware: MiddlewareHandler = async (c, next) => { - // 1. 多租户 LLM 配置:从 X-LLM-Config header 解析用户 API key,注入 ALS。 - // 后续 agent model(buildUserAwareModel)从 ALS 读取 → 按用户 key 调用 LLM。 - let userConfig: UserLLMConfig | undefined; - try { - const raw = c.req.header("X-LLM-Config"); - console.log("[identity-mw] X-LLM-Config header:", raw ? `${raw.slice(0, 50)}...` : "(empty)"); - if (raw && raw.trim()) { - const parsed = JSON.parse(raw) as Record; - if ( - typeof parsed.provider === "string" && - typeof parsed.api_key === "string" && - parsed.api_key.trim() !== "" - ) { - userConfig = { - id: typeof parsed.id === "string" ? parsed.id : "req", - provider: parsed.provider as UserLLMConfig["provider"], - model: typeof parsed.model === "string" ? parsed.model : undefined, - api_key: parsed.api_key, - custom_base_url: typeof parsed.custom_base_url === "string" ? parsed.custom_base_url : undefined, - custom_provider_name: typeof parsed.custom_provider_name === "string" ? parsed.custom_provider_name : undefined, - label: typeof parsed.label === "string" ? parsed.label : undefined, - }; - console.log("[identity-mw] Parsed userConfig:", { id: userConfig.id, provider: userConfig.provider, model: userConfig.model }); - } - } - } catch { - // 解析失败静默降级到系统 LLM - } - - // 2. JWT 身份注入 - try { - const authz = c.req.header("Authorization"); - const token = authz?.startsWith("Bearer ") ? authz.slice(7).trim() : undefined; - if (token) { - const payload = await verifyToken(token); - const sub = typeof payload.sub === "string" && payload.sub ? payload.sub : undefined; - if (sub) { - const rc = c.get("requestContext") as { set?: (k: string, v: unknown) => void } | undefined; - if (typeof rc?.set === "function") { - rc.set(AUTH_SUB_KEY, sub); - } else if (!_warnedNoRequestContext) { - // 防御性可观测:rc 缺失则已验证的 sub 被丢、askCache 静默落回 __global__、#91 隔离 - // 悄然复现。Mastra 升级若改了中间件初始化顺序最可能触发——进程内 warn 一次即够定位。 - _warnedNoRequestContext = true; - console.warn( - "[identity-mw] requestContext 不可用(无 .set)——authSub 未注入,askCache 落回 " + - "__global__;Mastra 升级后请复查中间件与 requestContext 初始化顺序(#91 隔离失效)。", - ); - } - } - } - } catch (err) { - // 过期 / 格式错 token 是正常用户行为(高频)→ 静默沿用 fallback,不阻断、不刷屏。 - // **只对签名验证失败**(ERR_JWS_SIGNATURE_VERIFICATION_FAILED)进程内 warn 一次:受信 - // dashboard→mastra 链路下签名失败基本=JWT_SECRET 配错,若系统性配错则每请求都触发、 - // 第一笔即告警,#91 隔离静默失效可见。把告警配额留给"配错"信号、不被高频过期 token - // 提前耗掉(CR #96 round3:旧 _warnedAuthFailure 会被过期 token 先消耗)。 - const code = (err as { code?: unknown } | null)?.code; - if (code === "ERR_JWS_SIGNATURE_VERIFICATION_FAILED" && !_warnedAuthSignature) { - _warnedAuthSignature = true; - console.warn( - "[identity-mw] Bearer 签名验证失败(多半 JWT_SECRET 配错)——authSub 未注入、" + - "askCache 落回 __global__、多租户 #91 隔离静默失效,请排查 JWT_SECRET。", - ); - } - } - // 3. 整个请求在 userLLMStore 上下文中执行 → agent model 可读取用户 LLM 配置 - if (userConfig) { - await userLLMStore.run(userConfig, next); - } else { - await next(); - } -}; - export const mastra = new Mastra({ storage: observabilityStore, // D-8a':只剩 orchestrator 一个 agent;trader/risk subagent 已废弃 diff --git a/packages/orchestration/tests/identity.test.ts b/packages/orchestration/tests/identity.test.ts new file mode 100644 index 00000000..2a8b5f05 --- /dev/null +++ b/packages/orchestration/tests/identity.test.ts @@ -0,0 +1,40 @@ +import { Hono } from "hono"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { identityMiddleware } from "../src/mastra/identity.js"; + +const TEST_KEY = "unique-test-key-must-never-reach-logs"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("identityMiddleware", () => { + it("does not log the raw LLM configuration header or API key", async () => { + const app = new Hono(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + app.use("*", identityMiddleware); + app.get("/", (context) => context.text("ok")); + + const response = await app.request("/", { + headers: { + "X-LLM-Config": JSON.stringify({ + id: "config-1", + provider: "anthropic", + model: "claude-test", + api_key: TEST_KEY, + }), + }, + }); + + expect(response.status).toBe(200); + const output = log.mock.calls.flat().map(String).join(" "); + expect(output).not.toContain(TEST_KEY); + expect(output).not.toContain("X-LLM-Config"); + expect(log).toHaveBeenCalledWith("[identity-mw] Parsed userConfig:", { + id: "config-1", + provider: "anthropic", + model: "claude-test", + }); + }); +}); From 76fe1c91f11669f72f272dce6ed82d287b7b3564 Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 23 Jul 2026 13:51:30 +0800 Subject: [PATCH 04/11] =?UTF-8?q?feat(infra):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=AE=89=E5=85=A8=20Docker=20=E8=87=AA=E6=89=98=E7=AE=A1?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提供本机绑定的完整 Docker 栈、账户初始化和用户级密钥配置文档,同时在构建与 CI 中防止环境密钥泄露或配置漂移。 Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 8 ++ .gitignore | 1 + README.md | 35 +++++++ README.zh-CN.md | 35 +++++++ infra/.env.prod.example | 2 +- infra/.env.selfhost.example | 32 +++++++ infra/README.md | 14 ++- infra/docker-compose.prod.yml | 9 +- infra/docker-compose.selfhost.yml | 18 ++++ packages/orchestration/.dockerignore | 4 + scripts/deploy.sh | 8 +- scripts/selfhost.sh | 136 +++++++++++++++++++++++++++ services/.dockerignore | 12 +++ 13 files changed, 307 insertions(+), 7 deletions(-) create mode 100644 infra/.env.selfhost.example create mode 100644 infra/docker-compose.selfhost.yml create mode 100755 scripts/selfhost.sh create mode 100644 services/.dockerignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7ca2dc5..aabc34bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,14 @@ jobs: - uses: actions/checkout@v4 - name: Run check-consistency.sh run: bash scripts/check-consistency.sh + - name: Validate self-host Compose + run: | + cp infra/.env.selfhost.example infra/.env.selfhost + for key in POSTGRES_PASSWORD REDIS_PASSWORD JWT_SECRET LLM_CONFIG_ENCRYPTION_KEY; do + sed -i "s/^${key}=.*/${key}=ci-test-secret/" infra/.env.selfhost + done + ENV_FILE=.env.selfhost docker compose -f infra/docker-compose.prod.yml -f infra/docker-compose.selfhost.yml --env-file infra/.env.selfhost config --quiet + rm infra/.env.selfhost orchestration-typecheck: name: orchestration · typecheck + test diff --git a/.gitignore b/.gitignore index 9060623c..df1b2d98 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ Cargo.lock .env.local .env.*.local .env.prod +infra/.env.selfhost *.pem *.key diff --git a/README.md b/README.md index 71d222a1..86ecf64f 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,41 @@ Where each capability stands today. Live module inventory and the end-to-end dec ## Quick Start +### Docker self-hosting (recommended) + +This is the supported Docker path for a personal server or local machine. It builds the complete stack locally: PostgreSQL/TimescaleDB, Redis, migrations, data, paper, research, factor, Mastra, and the Operator Console. + +Prerequisites: Docker Engine with Docker Compose v2, Git, and OpenSSL. Clone the repository, then initialize the local-only environment file and start the stack: + +```bash +git clone https://github.com/mirror29/inalpha.git +cd inalpha +bash scripts/selfhost.sh init +bash scripts/selfhost.sh up +``` + +The console is available only on the host at . Wait until `bash scripts/selfhost.sh status` shows the application services healthy, then create the first login account. The prompt reads the password without echoing it or placing it in shell history: + +```bash +bash scripts/selfhost.sh create-user --email you@example.com +``` + +Sign in at , open **LLM Settings**, and add your provider, model, and personal API key. Every authenticated user supplies their own key; it is encrypted in the database with `LLM_CONFIG_ENCRYPTION_KEY`. Do not add provider API keys to `infra/.env.selfhost`: authenticated production mode deliberately has no shared system-key fallback. + +Useful operations: + +```bash +bash scripts/selfhost.sh logs [service] +bash scripts/selfhost.sh status +bash scripts/selfhost.sh down +``` + +#### Public deployment + +The self-host Compose file intentionally exposes only `127.0.0.1:3001`. For remote access, place your own TLS-terminating Caddy, Nginx, or Cloudflare Tunnel in front of the Dashboard and proxy only that address. Never publish PostgreSQL, Redis, Mastra, or the Python service ports, and do not expose the login or API-key settings page over bare HTTP. + +### Local development + ### 1 · Install dependencies ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 8f15748c..78a687b8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -273,6 +273,41 @@ Inalpha 把*调度*和*算力*分开:agent runtime 负责扇出网格、聚合 ## Quick Start +### Docker 自托管(推荐) + +这是个人电脑或个人服务器的完整 Docker 启动路径:本地构建 PostgreSQL/TimescaleDB、Redis、迁移、data、paper、research、factor、Mastra 和操作者控制台。 + +前置条件:Docker Engine(含 Compose v2)、Git 与 OpenSSL。克隆仓库后,初始化仅保留在本机的环境文件,再启动全栈: + +```bash +git clone https://github.com/mirror29/inalpha.git +cd inalpha +bash scripts/selfhost.sh init +bash scripts/selfhost.sh up +``` + +控制台只绑定在宿主机 。等待 `bash scripts/selfhost.sh status` 显示应用服务 healthy,再创建第一个登录账号;命令会隐藏密码输入,不会把密码写进 shell history: + +```bash +bash scripts/selfhost.sh create-user --email you@example.com +``` + +在 登录,打开 **LLM Settings**,填写你的 provider、model 与个人 API key。每位已认证用户各自提供 key,控制台用 `LLM_CONFIG_ENCRYPTION_KEY` 加密后写入数据库。不要把 provider API key 填进 `infra/.env.selfhost`:认证生产模式刻意不支持共享系统 key fallback。 + +常用操作: + +```bash +bash scripts/selfhost.sh logs [service] +bash scripts/selfhost.sh status +bash scripts/selfhost.sh down +``` + +#### 公网访问 + +self-host Compose 故意只暴露 `127.0.0.1:3001`。需要远程访问时,用你自己的 Caddy、Nginx 或 Cloudflare Tunnel 为 Dashboard 做 TLS 终结,并且只代理该地址。不要公开 PostgreSQL、Redis、Mastra 或任何 Python service 端口;登录页和 API key 配置页不能通过裸 HTTP 暴露。 + +### 本地开发 + ### 1 · 安装依赖 ```bash diff --git a/infra/.env.prod.example b/infra/.env.prod.example index 8aa0d409..cee7d7f0 100644 --- a/infra/.env.prod.example +++ b/infra/.env.prod.example @@ -47,7 +47,7 @@ LLM_MODEL=deepseek-chat DEEPSEEK_API_KEY= ANTHROPIC_API_KEY= OPENAI_API_KEY= -GOOGLE_GENERATIVE_AI_API_KEY= +GEMINI_API_KEY= # ---- 行情数据源 ---- BINANCE_API_KEY= diff --git a/infra/.env.selfhost.example b/infra/.env.selfhost.example new file mode 100644 index 00000000..4eaf6bb3 --- /dev/null +++ b/infra/.env.selfhost.example @@ -0,0 +1,32 @@ +# Inalpha self-host environment +# Copy with: bash scripts/selfhost.sh init +# This file is local-only. Do not commit it or share it. + +POSTGRES_USER=quant +POSTGRES_PASSWORD= +POSTGRES_DB=inalpha +DATABASE_URL=postgresql+psycopg://quant:__POSTGRES_PASSWORD__@postgres:5432/inalpha + +REDIS_PASSWORD= +REDIS_URL=redis://:__REDIS_PASSWORD__@redis:6379/0 + +JWT_SECRET= +JWT_ALGORITHM=HS256 +LLM_CONFIG_ENCRYPTION_KEY= +AUTH_ENABLED=true +CONSOLE_SUBJECT=console:dev +CONSOLE_EMAIL=console@localhost +DASHBOARD_PORT=3001 +ENV_FILE=.env.selfhost +IMAGE_PREFIX=inalpha +IMAGE_TAG=latest + +# Each authenticated user configures their own LLM API key in the dashboard. +# Do not add provider API keys here as a shared fallback. + +# Optional market-data credentials +BINANCE_API_KEY= +BINANCE_API_SECRET= +FRED_API_KEY= +YFINANCE_PROXY_URL= +LIVE_RUNNER_RESUME_ON_STARTUP=false diff --git a/infra/README.md b/infra/README.md index 93ec228c..407989ea 100644 --- a/infra/README.md +++ b/infra/README.md @@ -2,7 +2,19 @@ 容器与数据库的基础设施。 -## 起服务 +## Docker 自托管全栈 + +完整自托管入口在仓库根目录运行,不使用本目录的开发数据库 Compose: + +```bash +bash scripts/selfhost.sh init +bash scripts/selfhost.sh up +bash scripts/selfhost.sh create-user --email you@example.com +``` + +它启动 PostgreSQL、Redis、迁移、四个 Python service、Mastra 与 Dashboard。Dashboard 仅绑定宿主机 `127.0.0.1:3001`;若要远程访问,应由部署者的 Caddy、Nginx 或 Tunnel 提供 HTTPS,并且只能代理 Dashboard。详细的用户级 LLM API key 配置与安全边界见根目录 README。 + +## 开发数据库与缓存 ```bash cd infra diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index 9fc9a529..cb73c8cd 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -25,7 +25,7 @@ x-svc-common: &svc-common condition: service_healthy migrate: condition: service_completed_successfully - env_file: [../infra/.env.prod] + env_file: ["../infra/${ENV_FILE:-.env.prod}"] services: postgres: @@ -75,7 +75,7 @@ services: args: { SERVICE: paper } # 复用 paper 镜像(含 DB 依赖);alembic 由 _shared/paper 提供 container_name: inalpha-migrate restart: "no" - env_file: [../infra/.env.prod] + env_file: ["../infra/${ENV_FILE:-.env.prod}"] depends_on: postgres: condition: service_healthy @@ -153,7 +153,7 @@ services: dockerfile: ../../infra/docker/Dockerfile.mastra container_name: inalpha-mastra restart: unless-stopped - env_file: [../infra/.env.prod] + env_file: ["../infra/${ENV_FILE:-.env.prod}"] environment: NODE_ENV: production # 服务间走容器名互访 @@ -221,12 +221,13 @@ services: # 公网入口:Cloudflare Tunnel(无需在 VPS 开入站端口,TLS/防护走 CF) cloudflared: + profiles: ["tunnel"] image: cloudflare/cloudflared:2026.6.0 container_name: inalpha-cloudflared restart: unless-stopped command: tunnel run environment: - TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN:?required} + TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN:-} # service_healthy:mastra 初始化完成前不开始对外转发(默认 service_started # 会让 Tunnel 在 mastra 还没就绪时把流量打到 502) depends_on: diff --git a/infra/docker-compose.selfhost.yml b/infra/docker-compose.selfhost.yml new file mode 100644 index 00000000..10a88b48 --- /dev/null +++ b/infra/docker-compose.selfhost.yml @@ -0,0 +1,18 @@ +services: + postgres: + image: timescale/timescaledb:2.27.2-pg17 + data: + build: {context: ../services, dockerfile: ../infra/docker/Dockerfile.python, args: {SERVICE: data}} + paper: + build: {context: ../services, dockerfile: ../infra/docker/Dockerfile.python, args: {SERVICE: paper}} + research: + build: {context: ../services, dockerfile: ../infra/docker/Dockerfile.python, args: {SERVICE: research}} + factor: + build: {context: ../services, dockerfile: ../infra/docker/Dockerfile.python, args: {SERVICE: factor}} + migrate: + build: {context: ../services, dockerfile: ../infra/docker/Dockerfile.python, args: {SERVICE: paper}} + mastra: + build: {context: ../packages/orchestration, dockerfile: ../../infra/docker/Dockerfile.mastra} + dashboard: + build: {context: ../apps/dashboard, dockerfile: ../../infra/docker/Dockerfile.dashboard} + ports: ["127.0.0.1:${DASHBOARD_PORT:-3001}:3001"] diff --git a/packages/orchestration/.dockerignore b/packages/orchestration/.dockerignore index 2c121161..2c7a4b1e 100644 --- a/packages/orchestration/.dockerignore +++ b/packages/orchestration/.dockerignore @@ -2,4 +2,8 @@ node_modules .mastra dist .turbo +.env* +*.pem +*.key +.git *.log diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 9f572898..c01590c3 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -22,7 +22,8 @@ cd "$ROOT" COMPOSE_FILE="infra/docker-compose.prod.yml" ENV_FILE="infra/.env.prod" -DC=(docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE") +export ENV_FILE=.env.prod +DC=(docker compose --profile tunnel -f "$COMPOSE_FILE" --env-file "$ENV_FILE") do_git_pull=1 mode="image" # image | build @@ -41,6 +42,11 @@ done exit 1 } +if ! grep -qE '^CLOUDFLARE_TUNNEL_TOKEN=.+$' "$ENV_FILE"; then + echo "缺 CLOUDFLARE_TUNNEL_TOKEN ——生产 tunnel profile 无法启动" >&2 + exit 1 +fi + if [ "$do_git_pull" -eq 1 ]; then echo "==> git pull --ff-only" git pull --ff-only diff --git a/scripts/selfhost.sh b/scripts/selfhost.sh new file mode 100755 index 00000000..4a9f6628 --- /dev/null +++ b/scripts/selfhost.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SELFHOST_ENV_FILE="$ROOT/infra/.env.selfhost" +export ENV_FILE=.env.selfhost +EXAMPLE_FILE="$ROOT/infra/.env.selfhost.example" +COMPOSE=(docker compose -f "$ROOT/infra/docker-compose.prod.yml" -f "$ROOT/infra/docker-compose.selfhost.yml" --env-file "$SELFHOST_ENV_FILE") + +usage() { + cat <<'EOF' +Usage: bash scripts/selfhost.sh [args] + +Commands: + init Create infra/.env.selfhost with generated secrets + up Build and start the self-host stack + down Stop the self-host stack + logs [service] Follow service logs + status Show service status + create-user --email EMAIL Create the initial dashboard user securely +EOF +} + +require_env() { + if [[ ! -f "$SELFHOST_ENV_FILE" ]]; then + printf 'Missing %s. Run: bash scripts/selfhost.sh init\n' "$SELFHOST_ENV_FILE" >&2 + exit 1 + fi +} + +generate_secret() { + openssl rand -hex 32 +} + +init() { + if [[ -e "$SELFHOST_ENV_FILE" ]]; then + printf '%s already exists; refusing to overwrite it.\n' "$SELFHOST_ENV_FILE" >&2 + exit 1 + fi + cp "$EXAMPLE_FILE" "$SELFHOST_ENV_FILE" + local postgres_password redis_password jwt_secret encryption_key + postgres_password="$(generate_secret)" + redis_password="$(generate_secret)" + jwt_secret="$(generate_secret)" + encryption_key="$(generate_secret)" + python3 - "$SELFHOST_ENV_FILE" "$postgres_password" "$redis_password" "$jwt_secret" "$encryption_key" <<'PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +postgres_password, redis_password, jwt_secret, encryption_key = sys.argv[2:] +content = path.read_text() +content = content.replace("POSTGRES_PASSWORD=\n", f"POSTGRES_PASSWORD={postgres_password}\n") +content = content.replace("REDIS_PASSWORD=\n", f"REDIS_PASSWORD={redis_password}\n") +content = content.replace("JWT_SECRET=\n", f"JWT_SECRET={jwt_secret}\n") +content = content.replace("LLM_CONFIG_ENCRYPTION_KEY=\n", f"LLM_CONFIG_ENCRYPTION_KEY={encryption_key}\n") +content = content.replace("__POSTGRES_PASSWORD__", postgres_password) +content = content.replace("__REDIS_PASSWORD__", redis_password) +path.write_text(content) +PY + chmod 600 "$SELFHOST_ENV_FILE" + printf 'Created infra/.env.selfhost with generated secrets.\n' + printf 'Next: bash scripts/selfhost.sh up\n' +} + +create_user() { + require_env + local email="" + while (($#)); do + case "$1" in + --email) + email="${2:-}" + shift 2 + ;; + -h|--help) + usage + return + ;; + *) + printf 'Unknown create-user argument: %s\n' "$1" >&2 + exit 2 + ;; + esac + done + if [[ -z "$email" ]]; then + printf 'create-user requires --email EMAIL\n' >&2 + exit 2 + fi + local password + read -r -s -p "Password: " password + printf '\n' + if [[ -z "$password" ]]; then + printf 'Password cannot be empty.\n' >&2 + exit 2 + fi + printf '%s' "$password" | "${COMPOSE[@]}" run --rm -T paper \ + uv run python scripts/create_user.py \ + --email "$email" --subject console:dev --password-stdin +} + +command="${1:-}" +case "$command" in + init) + init + ;; + up) + require_env + "${COMPOSE[@]}" up -d --build + "${COMPOSE[@]}" ps + ;; + down) + require_env + "${COMPOSE[@]}" down + ;; + logs) + require_env + shift + "${COMPOSE[@]}" logs -f "$@" + ;; + status) + require_env + "${COMPOSE[@]}" ps + ;; + create-user) + shift + create_user "$@" + ;; + -h|--help|"") + usage + ;; + *) + printf 'Unknown command: %s\n' "$command" >&2 + usage >&2 + exit 2 + ;; +esac diff --git a/services/.dockerignore b/services/.dockerignore new file mode 100644 index 00000000..3ddae5fe --- /dev/null +++ b/services/.dockerignore @@ -0,0 +1,12 @@ +# Python service build contexts +.env* +*.pem +*.key +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.venv/ +*.py[cod] +coverage/ +htmlcov/ +.git/ From 6f01b54a9c715b46e57d27ead81ff48a99fd5403 Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 23 Jul 2026 16:44:02 +0800 Subject: [PATCH 05/11] =?UTF-8?q?fix(infra):=20=E9=9A=94=E7=A6=BB=E8=87=AA?= =?UTF-8?q?=E6=89=98=E7=AE=A1=E5=AE=B9=E5=99=A8=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- infra/docker-compose.prod.yml | 9 +++++---- infra/docker-compose.selfhost.yml | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index cb73c8cd..03b1e7fb 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -1,12 +1,13 @@ name: inalpha-prod -# Inalpha 生产部署(单机 / Hetzner VPS)—— 见 docs/miro/decisions/0042 +# Inalpha 生产部署(单机 / Hetzner VPS) # -# 起: docker compose -f infra/docker-compose.prod.yml --env-file infra/.env.prod up -d --build -# 迁移:migrate 服务一次性跑 alembic upgrade head(profile=migrate 或随 up 自动先跑) +# 起: docker compose --profile tunnel -f infra/docker-compose.prod.yml --env-file infra/.env.prod up -d --build +# 迁移:migrate 服务一次性跑 alembic upgrade head(随 up 自动先跑) # 停: docker compose -f infra/docker-compose.prod.yml down # -# 拓扑:postgres + redis + migrate(一次性) + data/paper/research/factor + mastra + cloudflared +# 拓扑:postgres + redis + migrate(一次性) + data/paper/research/factor + mastra + dashboard; +# --profile tunnel 时另启 cloudflared。 # 仅 cloudflared 对公网;其余服务走容器内网,按服务名互访。 # # 镜像策略(见 0058):各服务同时声明 image: + build:。 diff --git a/infra/docker-compose.selfhost.yml b/infra/docker-compose.selfhost.yml index 10a88b48..5d30f059 100644 --- a/infra/docker-compose.selfhost.yml +++ b/infra/docker-compose.selfhost.yml @@ -1,18 +1,29 @@ +name: inalpha-selfhost + services: postgres: + container_name: inalpha-selfhost-postgres image: timescale/timescaledb:2.27.2-pg17 + redis: + container_name: inalpha-selfhost-redis + migrate: + container_name: inalpha-selfhost-migrate data: + container_name: inalpha-selfhost-data build: {context: ../services, dockerfile: ../infra/docker/Dockerfile.python, args: {SERVICE: data}} paper: + container_name: inalpha-selfhost-paper build: {context: ../services, dockerfile: ../infra/docker/Dockerfile.python, args: {SERVICE: paper}} research: + container_name: inalpha-selfhost-research build: {context: ../services, dockerfile: ../infra/docker/Dockerfile.python, args: {SERVICE: research}} factor: + container_name: inalpha-selfhost-factor build: {context: ../services, dockerfile: ../infra/docker/Dockerfile.python, args: {SERVICE: factor}} - migrate: - build: {context: ../services, dockerfile: ../infra/docker/Dockerfile.python, args: {SERVICE: paper}} mastra: + container_name: inalpha-selfhost-mastra build: {context: ../packages/orchestration, dockerfile: ../../infra/docker/Dockerfile.mastra} dashboard: + container_name: inalpha-selfhost-dashboard build: {context: ../apps/dashboard, dockerfile: ../../infra/docker/Dockerfile.dashboard} ports: ["127.0.0.1:${DASHBOARD_PORT:-3001}:3001"] From 61f26174463f9f47c17032ed3372562a55305ef1 Mon Sep 17 00:00:00 2001 From: Miro Date: Mon, 27 Jul 2026 11:34:38 +0800 Subject: [PATCH 06/11] =?UTF-8?q?fix(orchestration):=20=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=BB=B6=E8=BF=9F=E5=8A=A0=E8=BD=BD=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../orchestration/src/mastra/llm/provider.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/orchestration/src/mastra/llm/provider.ts b/packages/orchestration/src/mastra/llm/provider.ts index bd40b38e..236aa9d5 100644 --- a/packages/orchestration/src/mastra/llm/provider.ts +++ b/packages/orchestration/src/mastra/llm/provider.ts @@ -276,7 +276,13 @@ export function buildLLMForUser(userConfig: UserLLMConfig | null): LanguageModel * @returns 代理 LanguageModel */ export function buildUserAwareModel(): LanguageModel { - const defaultModel = buildLLM(); + const defaultModel = AUTH_ENABLED ? null : buildLLM(); + const proxyTarget = (defaultModel ?? { + specificationVersion: "v1", + provider: "inalpha-user", + modelId: "user-configured", + defaultObjectGenerationMode: undefined, + }) as LanguageModel; // 以 ALS store 为 key 缓存 per-request model;无 config 时返回 null(用 default)。 const modelCache = new Map(); @@ -290,9 +296,9 @@ export function buildUserAwareModel(): LanguageModel { throw new Error("AUTH_ENABLED=true 但用户未配置 LLM API Key"); } - if (!config) return defaultModel; + if (!config) return defaultModel!; const cached = modelCache.get(config); - if (cached !== undefined) return cached ?? defaultModel; + if (cached !== undefined) return cached ?? defaultModel!; try { console.log("[llm] Building model for user config:", config.provider, config.model); const m = buildLLMForUser(config) as unknown as LanguageModel; @@ -305,14 +311,14 @@ export function buildUserAwareModel(): LanguageModel { } // Proxy:拦截 doGenerate / doStream,其他属性透传 defaultModel。 - return new Proxy(defaultModel, { + return new Proxy(proxyTarget, { get(_target, prop, receiver) { if (prop === "doGenerate" || prop === "doStream") { const m = resolveModel(); if (m === defaultModel) return Reflect.get(defaultModel, prop, receiver); return Reflect.get(m, prop, receiver); } - return Reflect.get(defaultModel, prop, receiver); + return Reflect.get(proxyTarget, prop, receiver); }, }) as unknown as LanguageModel; } From 4649c193573a4b61cf7ed01196911bd10da30100 Mon Sep 17 00:00:00 2001 From: Miro Date: Mon, 27 Jul 2026 11:34:59 +0800 Subject: [PATCH 07/11] =?UTF-8?q?fix(dashboard):=20=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E6=89=93=E5=BC=80=20LLM=20=E9=85=8D=E7=BD=AE=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E7=BF=BB=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- apps/dashboard/messages/en.json | 36 +++++++++ apps/dashboard/messages/zh.json | 36 +++++++++ apps/dashboard/src/app/[locale]/layout.tsx | 2 + .../src/components/chat/ChatThread.tsx | 5 +- .../src/components/llm/LLMConfigGate.tsx | 39 ++++++++++ .../src/components/llm/LLMConfigModal.tsx | 78 ++++++++++--------- .../components/overview/OverviewClient.tsx | 40 +--------- .../src/components/shell/ConsoleSidebar.tsx | 9 ++- 8 files changed, 164 insertions(+), 81 deletions(-) create mode 100644 apps/dashboard/src/components/llm/LLMConfigGate.tsx diff --git a/apps/dashboard/messages/en.json b/apps/dashboard/messages/en.json index 9f45b0fe..2755c665 100644 --- a/apps/dashboard/messages/en.json +++ b/apps/dashboard/messages/en.json @@ -409,6 +409,42 @@ "none": "—", "truncated": "Showing the latest {n} — earlier ones aren't listed here." }, + "llm": { + "title": "LLM Settings", + "loading": "Loading…", + "loadFailed": "Could not load settings: {error}", + "noConfigs": "No configuration yet. Add an API key to start.", + "active": "Active", + "model": "Model: {model}", + "defaultModel": "default", + "key": "Key: {key}", + "delete": "Delete", + "provider": "Provider", + "customEndpoint": "Custom endpoint URL", + "customName": "Custom name", + "customNamePlaceholder": "My provider", + "customProvider": "Custom endpoint", + "modelOptional": "Model (optional)", + "modelPlaceholder": "Leave blank to use the default", + "apiKey": "API Key *", + "saving": "Saving…", + "save": "Save", + "cancel": "Cancel", + "addConfig": "Add configuration", + "saveFailed": "Could not save configuration", + "saveMissingId": "Could not save configuration: no configuration ID returned", + "saveActivationFailed": "Configuration was saved, but could not be activated", + "saved": "Configuration saved", + "activated": "Configuration activated", + "activationFailed": "Could not activate configuration", + "deleted": "Configuration deleted", + "deleteFailed": "Could not delete configuration", + "confirmDelete": "Delete configuration?", + "deleteDescription": "Delete the configuration for {provider}? This cannot be undone.", + "deleting": "Deleting…", + "sidebarEntry": "LLM Settings", + "invalidKey": "The active LLM API key is invalid or temporarily unavailable. Check the configuration and retry." + }, "chat": { "title": "Agent", "online": "online", diff --git a/apps/dashboard/messages/zh.json b/apps/dashboard/messages/zh.json index a8a8c970..12c916d6 100644 --- a/apps/dashboard/messages/zh.json +++ b/apps/dashboard/messages/zh.json @@ -409,6 +409,42 @@ "none": "—", "truncated": "仅显示最近 {n} 条 —— 更早的未在此列出。" }, + "llm": { + "title": "LLM 配置", + "loading": "加载中…", + "loadFailed": "加载失败:{error}", + "noConfigs": "暂无配置,请添加 API Key", + "active": "当前", + "model": "模型:{model}", + "defaultModel": "默认", + "key": "密钥:{key}", + "delete": "删除", + "provider": "供应商", + "customEndpoint": "自定义端点 URL", + "customName": "自定义名称", + "customNamePlaceholder": "我的供应商", + "customProvider": "自定义端点", + "modelOptional": "模型(可选)", + "modelPlaceholder": "留空使用默认", + "apiKey": "API Key *", + "saving": "保存中…", + "save": "保存", + "cancel": "取消", + "addConfig": "新增配置", + "saveFailed": "保存失败", + "saveMissingId": "保存失败:未返回配置 ID", + "saveActivationFailed": "配置已保存,但激活失败", + "saved": "配置已保存", + "activated": "已切换配置", + "activationFailed": "切换失败", + "deleted": "配置已删除", + "deleteFailed": "删除失败", + "confirmDelete": "确认删除", + "deleteDescription": "确定要删除配置 {provider} 吗?此操作无法撤销。", + "deleting": "删除中…", + "sidebarEntry": "LLM 配置", + "invalidKey": "当前 LLM API Key 无效或暂时不可用,请检查配置后重试" + }, "chat": { "title": "Agent", "online": "在线", diff --git a/apps/dashboard/src/app/[locale]/layout.tsx b/apps/dashboard/src/app/[locale]/layout.tsx index 6ce35e0c..b92f06f6 100644 --- a/apps/dashboard/src/app/[locale]/layout.tsx +++ b/apps/dashboard/src/app/[locale]/layout.tsx @@ -4,6 +4,7 @@ import { notFound } from "next/navigation"; import { ActivityFooter } from "@/components/activity/ActivityFooter"; import { ConsoleChat } from "@/components/chat/ConsoleChat"; +import { LLMConfigGate } from "@/components/llm/LLMConfigGate"; import { ConsoleSidebar } from "@/components/shell/ConsoleSidebar"; import { Toaster } from "@/components/ui/sonner"; import { routing } from "@/i18n/routing"; @@ -42,6 +43,7 @@ export default async function LocaleLayout({ {/* 内嵌 agent 对话栏 —— 常驻 layout,切面切换不丢对话(见 ConsoleChat)。 */} + {/* 常驻底部活动日志(终端风)—— 随时可回溯 agent 跨模块活动(见 ActivityFooter)。 */} diff --git a/apps/dashboard/src/components/chat/ChatThread.tsx b/apps/dashboard/src/components/chat/ChatThread.tsx index cba21048..bfb86b05 100644 --- a/apps/dashboard/src/components/chat/ChatThread.tsx +++ b/apps/dashboard/src/components/chat/ChatThread.tsx @@ -59,6 +59,7 @@ export function ChatThread({ onSwitchThread: (id: string) => void; }) { const t = useTranslations("chat"); + const tLlm = useTranslations("llm"); const hook = useCopilotChatInternal(); const messages = (hook.messages ?? []) as unknown as AGMessage[]; @@ -91,14 +92,14 @@ export function ChatThread({ if (url.includes("/api/copilotkit") && res.status === 428) { window.dispatchEvent(new CustomEvent("inalpha:open-llm-settings")); } else if (url.includes("/api/copilotkit") && res.status === 401) { - setChatError("当前 LLM API Key 无效或暂时不可用,请检查配置后重试"); + setChatError(tLlm("invalidKey")); } return res; }; (patched as { __inalphaLLMCheck?: boolean }).__inalphaLLMCheck = true; window.fetch = patched; return () => { if (window.fetch === patched) window.fetch = orig; }; - }, []); + }, [tLlm]); const loadedThreadRef = useRef(null); const setMessagesRef = useRef(setMessages); diff --git a/apps/dashboard/src/components/llm/LLMConfigGate.tsx b/apps/dashboard/src/components/llm/LLMConfigGate.tsx new file mode 100644 index 00000000..c7ecd054 --- /dev/null +++ b/apps/dashboard/src/components/llm/LLMConfigGate.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { LLMConfigModal } from "./LLMConfigModal"; + +const LS_DISMISSED = "inalpha-llm-config-dismissed"; + +/** 在所有控制台页面管理用户 LLM 配置入口。 */ +export function LLMConfigGate() { + const [open, setOpen] = useState(false); + + useEffect(() => { + const openSettings = () => setOpen(true); + window.addEventListener("inalpha:open-llm-settings", openSettings); + + if (!localStorage.getItem(LS_DISMISSED)) { + void fetch("/api/user/settings") + .then((response) => response.ok ? response.json() : null) + .then((settings) => { + if (settings && (!settings.configs || settings.configs.length === 0)) { + setOpen(true); + } + }); + } + + return () => window.removeEventListener("inalpha:open-llm-settings", openSettings); + }, []); + + return ( + { + setOpen(false); + localStorage.setItem(LS_DISMISSED, "1"); + }} + /> + ); +} diff --git a/apps/dashboard/src/components/llm/LLMConfigModal.tsx b/apps/dashboard/src/components/llm/LLMConfigModal.tsx index 44f39439..4040c457 100644 --- a/apps/dashboard/src/components/llm/LLMConfigModal.tsx +++ b/apps/dashboard/src/components/llm/LLMConfigModal.tsx @@ -3,6 +3,7 @@ */ "use client"; +import { useTranslations } from "next-intl"; import { useState, useEffect, useCallback } from "react"; import { Plus, Trash2, Key, Settings, AlertTriangle } from "lucide-react"; import type { LLMProvider, UserLLMConfigDisplay } from "@/lib/user-preferences"; @@ -44,6 +45,7 @@ export function clearLLMConfigDismissed(): void { } export function LLMConfigModal({ open, onClose }: { open: boolean; onClose: () => void }) { + const t = useTranslations("llm"); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -69,15 +71,15 @@ export function LLMConfigModal({ open, onClose }: { open: boolean; onClose: () = setLoading(true); setError(null); const res = await fetch("/api/user/settings"); - if (!res.ok) throw new Error("Failed to fetch settings"); + if (!res.ok) throw new Error(t("loadFailed", { error: res.status })); const data = await res.json(); setSettings(data); } catch (err) { - setError(err instanceof Error ? err.message : "Unknown error"); + setError(err instanceof Error ? err.message : t("loadFailed", { error: "unknown" })); } finally { setLoading(false); } - }, []); + }, [t]); useEffect(() => { if (open) { @@ -96,21 +98,21 @@ export function LLMConfigModal({ open, onClose }: { open: boolean; onClose: () = headers: { "Content-Type": "application/json" }, body: JSON.stringify(formData), }); - if (!res.ok) throw new Error("保存失败"); + if (!res.ok) throw new Error(t("saveFailed")); const { id } = (await res.json()) as { id?: string }; - if (!id) throw new Error("保存失败:未返回配置 ID"); + if (!id) throw new Error(t("saveMissingId")); const activateRes = await fetch("/api/user/settings/activate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ config_id: id }), }); - if (!activateRes.ok) throw new Error("配置已保存,但激活失败"); + if (!activateRes.ok) throw new Error(t("saveActivationFailed")); setShowAddForm(false); setFormData({ provider: "deepseek", model: "", api_key: "", custom_base_url: "", custom_provider_name: "", label: "" }); await fetchSettings(); - toast.success("配置已保存"); + toast.success(t("saved")); } catch (err) { - toast.error(err instanceof Error ? err.message : "保存失败"); + toast.error(err instanceof Error ? err.message : t("saveFailed")); } finally { setSaving(false); } @@ -136,11 +138,11 @@ export function LLMConfigModal({ open, onClose }: { open: boolean; onClose: () = headers: { "Content-Type": "application/json" }, body: JSON.stringify({ config_id: configId }), }); - toast.success("已切换配置"); + toast.success(t("activated")); } catch { // 失败时回滚 fetchSettings(); - toast.error("切换失败"); + toast.error(t("activationFailed")); } } @@ -149,12 +151,12 @@ export function LLMConfigModal({ open, onClose }: { open: boolean; onClose: () = setDeleting(true); try { const res = await fetch(`/api/user/settings/${deleteTarget.id}`, { method: "DELETE" }); - if (!res.ok) throw new Error("删除失败"); + if (!res.ok) throw new Error(t("deleteFailed")); setDeleteTarget(null); await fetchSettings(); - toast.success("配置已删除"); + toast.success(t("deleted")); } catch (err) { - toast.error(err instanceof Error ? err.message : "删除失败"); + toast.error(err instanceof Error ? err.message : t("deleteFailed")); } finally { setDeleting(false); } @@ -167,19 +169,19 @@ export function LLMConfigModal({ open, onClose }: { open: boolean; onClose: () = - LLM 配置 + {t("title")} {loading && (
-
加载中...
+
{t("loading")}
)} {error && (
- 加载失败: {error} + {error}
)} @@ -206,13 +208,13 @@ export function LLMConfigModal({ open, onClose }: { open: boolean; onClose: () = {config.is_active && ( - 当前 + {t("active")} )}
-
模型: {config.model || "默认"}
-
Key: {config.api_key_masked}
+
{t("model", { model: config.model || t("defaultModel") })}
+
{t("key", { key: config.api_key_masked })}
@@ -224,7 +226,7 @@ export function LLMConfigModal({ open, onClose }: { open: boolean; onClose: () = e.stopPropagation(); setDeleteTarget(config); }} - title="删除" + title={t("delete")} className="size-7 hover:text-fox-red hover:bg-fox-red/10" > @@ -237,7 +239,7 @@ export function LLMConfigModal({ open, onClose }: { open: boolean; onClose: () = {(!settings || settings.configs.length === 0) && (
-

暂无配置,请添加 API Key

+

{t("noConfigs")}

)} @@ -246,7 +248,7 @@ export function LLMConfigModal({ open, onClose }: { open: boolean; onClose: () = {showAddForm ? (
- +
{formData.provider === "custom" && ( <>
- +
- + setFormData({ ...formData, custom_provider_name: e.target.value })} - placeholder="某中转站" + placeholder={t("customNamePlaceholder")} />
)}
- + setFormData({ ...formData, model: e.target.value })} - placeholder="留空使用默认" + placeholder={t("modelPlaceholder")} />
- +
) : ( )} @@ -335,18 +337,18 @@ export function LLMConfigModal({ open, onClose }: { open: boolean; onClose: () = - 确认删除 + {t("confirmDelete")} - 确定要删除配置 {deleteTarget?.custom_provider_name || deleteTarget?.provider} 吗? -
- 此操作无法撤销。 + {t("deleteDescription", { + provider: deleteTarget?.custom_provider_name || deleteTarget?.provider || "", + })}
- 取消 + {t("cancel")} - {deleting ? "删除中..." : "删除"} + {deleting ? t("deleting") : t("delete")} diff --git a/apps/dashboard/src/components/overview/OverviewClient.tsx b/apps/dashboard/src/components/overview/OverviewClient.tsx index e348c243..8506d93e 100644 --- a/apps/dashboard/src/components/overview/OverviewClient.tsx +++ b/apps/dashboard/src/components/overview/OverviewClient.tsx @@ -2,7 +2,7 @@ import { useTranslations } from "next-intl"; import useSWR from "swr"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import type { OverviewPayload } from "@/lib/types"; import { jsonFetcher } from "@/lib/fetcher"; @@ -15,7 +15,6 @@ import { OrdersTable } from "./OrdersTable"; import { PositionsTable } from "./PositionsTable"; import { RunnersPanel } from "./RunnersPanel"; import { StrategyPanel } from "./StrategyPanel"; -import { LLMConfigModal } from "@/components/llm/LLMConfigModal"; /** 账户/持仓/订单变化较慢,8s 一档(见设计文档轮询节奏)。 */ const REFRESH_MS = 8000; @@ -30,40 +29,6 @@ export function OverviewClient() { keepPreviousData: true, // 刷新失败/进行中时保留上一帧,不闪烁 }); - // ── LLM 配置弹窗(首次进入检测)── - const [llmModalOpen, setLlmModalOpen] = useState(false); - - useEffect(() => { - // 监听侧边栏「LLM 配置」点击事件 - const onOpen = () => setLlmModalOpen(true); - window.addEventListener("inalpha:open-llm-settings", onOpen); - return () => window.removeEventListener("inalpha:open-llm-settings", onOpen); - }, []); - - // 数据加载完成后,检测用户是否有 LLM 配置 - useEffect(() => { - if (!data) return; // 还没加载完 - const dismissed = localStorage.getItem("inalpha-llm-config-dismissed"); - if (dismissed) return; // 用户已关闭过 - - // 异步检查用户是否有 LLM 配置 - fetch("/api/user/settings") - .then((r) => r.json()) - .then((res) => { - if (!res.configs || res.configs.length === 0) { - setLlmModalOpen(true); - } - }) - .catch(() => { - // 静默失败,不弹窗 - }); - }, [data]); - - const closeLlmModal = () => { - setLlmModalOpen(false); - localStorage.setItem("inalpha-llm-config-dismissed", "1"); - }; - if (isLoading && !data) { return ; } @@ -133,9 +98,6 @@ export function OverviewClient() { - - {/* LLM 配置弹窗 */} - ); } diff --git a/apps/dashboard/src/components/shell/ConsoleSidebar.tsx b/apps/dashboard/src/components/shell/ConsoleSidebar.tsx index 1a8ce451..7f5e8d0b 100644 --- a/apps/dashboard/src/components/shell/ConsoleSidebar.tsx +++ b/apps/dashboard/src/components/shell/ConsoleSidebar.tsx @@ -72,6 +72,7 @@ const LS_COLLAPSED = "inalpha-sidebar-collapsed"; */ export function ConsoleSidebar() { const t = useTranslations("nav"); + const tLlm = useTranslations("llm"); const pathname = usePathname(); const [collapsed, setCollapsed] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); @@ -151,6 +152,7 @@ export function ConsoleSidebar() { > setMobileOpen(false)} @@ -189,6 +192,7 @@ export function ConsoleSidebar() { /** 栏体(品牌 + 导航 + 控制区)—— 桌面栏与移动抽屉共用。 */ function SidebarBody({ t, + llmLabel, pathname, collapsed, onToggleCollapsed, @@ -197,6 +201,7 @@ function SidebarBody({ buildDate, }: { t: ReturnType; + llmLabel: string; pathname: string; collapsed: boolean; onToggleCollapsed?: () => void; @@ -443,7 +448,7 @@ function SidebarBody({ }} > - LLM 配置 + {llmLabel} @@ -482,7 +487,7 @@ function SidebarBody({ }} > - LLM 配置 + {llmLabel} From 936578b3153f97cee9bf2a84158133946a1d240a Mon Sep 17 00:00:00 2001 From: Miro Date: Mon, 27 Jul 2026 14:07:45 +0800 Subject: [PATCH 08/11] =?UTF-8?q?fix(orchestration):=20=E6=A8=A1=E6=8B=9F?= =?UTF-8?q?=E7=9B=98=E5=90=AF=E5=8A=A8=E5=8F=AA=E9=9C=80=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将候选转正与模拟盘 runner 启动纳入同一受确认的操作,避免审计文案变化或流程拆分导致重复确认。 Co-Authored-By: Claude Sonnet 4.6 --- .../config/permissions.default.yaml | 2 + .../mastra/agents/instructions/strategy.ts | 20 +-- .../agents/instructions/tool-catalog.ts | 16 ++- .../src/permissions/approval-identity.ts | 12 ++ .../orchestration/src/permissions/defaults.ts | 2 + packages/orchestration/src/tools/index.ts | 5 + packages/orchestration/src/tools/paper.ts | 56 +++++++++ .../tests/approval-identity.test.ts | 29 +++++ .../orchestration/tests/ask-path-e2e.test.ts | 44 +++++++ packages/orchestration/tests/tools.test.ts | 118 +++++++++++++++++- 10 files changed, 284 insertions(+), 20 deletions(-) diff --git a/packages/orchestration/config/permissions.default.yaml b/packages/orchestration/config/permissions.default.yaml index 7dfb0211..7c008f73 100644 --- a/packages/orchestration/config/permissions.default.yaml +++ b/packages/orchestration/config/permissions.default.yaml @@ -79,6 +79,8 @@ ask: # D-9 · 候选 → 正式策略(ADR-0018 / D-9.1b:askUserChoice 接通后改回 ask) # 后端硬校验仍在(fitness IS NOT NULL + status='candidate')作为第二道防线 - "paper.promote_candidate" + # 用户明确启动模拟盘时,一次确认同时授权转正与 runner 启动。 + - "paper.promote_and_start_strategy" # 账户外生资金事件:改钱=改绩效口径,必须人点头(流水留痕是第二道防线)。 # reset 是破坏性操作(删全部持仓行),后端另有 running-run 409 硬守门。 diff --git a/packages/orchestration/src/mastra/agents/instructions/strategy.ts b/packages/orchestration/src/mastra/agents/instructions/strategy.ts index 7c1db6d1..d7d55471 100644 --- a/packages/orchestration/src/mastra/agents/instructions/strategy.ts +++ b/packages/orchestration/src/mastra/agents/instructions/strategy.ts @@ -131,16 +131,16 @@ data.* / paper.run_backtest 的 fromTs / toTs 都是 optional,省略时默认" 6. **会话驱动里不存在"系统超时"**:requiresApproval 不会自动失效翻成 deny, 也不会自动放行——它就是个"需要用户口头同意"的信号。用户没回 / 跳话题 时**不要**说"等了太久所以取消了",按上面 case 4 主动澄清 -- promote 成功后**必须明确告诉用户**:候选已加入正式策略池,但 **promote 本身只是 - 状态切换、不会自动开始交易**。接下来有两条路:(1) 走 trade.create_plan 手动下单; - (2) 调 **paper.start_strategy** 把它放到模拟盘**按行情自动跑**(D-11 live runner 已实现)。 - start 是独立的人工动作——不要 promote 完就默认替用户起。 - 此外 promote 成功时系统会**自动触发一轮演化**(E2 hook,budget=2 小规模探索), - 以你刚 promote 的代码为种子继续变异探索下一代表现更好的候选。这是后台异步运行的, - 几轮对话后可用 evolver.get_evolution 查看结果。 -- 用户问"可以下单了吗 / live runner 能用了吗"——status='candidate' 时先让他 promote; - status='promoted' 时如实说"**能**:手动下单走 trade.create_plan,或 paper.start_strategy - 让它自动盯盘跑模拟盘"。**不要再说"自动按行情运行还没实现 / 在 E2 排队"——D-11 已经做了。** +- 用户明确要求“放到模拟盘自动跑 / 启动 runner / 跟行情运行”,且候选仍是 candidate 时: + 1. 调 **paper.promote_and_start_strategy**。第一次会返 requiresApproval=true;向用户一次说明 + 转正依据 + 将启动的 venue / symbol / timeframe / allocation,然后停下等明确同意。 + 2. 用户明确同意后,**以同一份执行参数**重调该工具。它会连续完成转正和 runner 启动; + 成功后直接报告 runner 已运行,**绝不再要求第二次确认**。 + 3. 用户只说“转正 / promote”时才用 paper.promote_candidate;已 promoted 后单独启动仍用 + paper.start_strategy。 +- 用户问"可以下单了吗 / live runner 能用了吗"——status='candidate' 时如实说明需要一次确认后 + 通过 paper.promote_and_start_strategy 投入模拟盘;status='promoted' 时如实说"能:调 + paper.start_strategy 让它按行情自动跑"。 - **跟用户讲话用人话**,不要直接说 tool id / 英文术语: - paper.promote_candidate → "把这条策略转为正式 / 加入正式策略池" - candidate → "草稿策略";promoted → "正式策略" diff --git a/packages/orchestration/src/mastra/agents/instructions/tool-catalog.ts b/packages/orchestration/src/mastra/agents/instructions/tool-catalog.ts index e759ead3..e7ecb992 100644 --- a/packages/orchestration/src/mastra/agents/instructions/tool-catalog.ts +++ b/packages/orchestration/src/mastra/agents/instructions/tool-catalog.ts @@ -106,17 +106,15 @@ export const TOOL_CATALOG = ` - paper.author_strategy —— 你自己写 Python Strategy 子类源码 → 沙盒审计 → 落候选表 → 返 candidate_id - paper.list_candidates —— 列已落库的候选(按 fitness DESC),看 leaderboard - paper.get_candidate —— 按 ID 取完整候选(含源码 + 最近 metrics + fitness) -- paper.promote_candidate —— 把候选从 'candidate' 切到 'promoted'(D-9.1b 起 permission='ask', - 返 requiresApproval=true——需要你在 chat 里向用户清楚说明候选信息 + - 等用户明确回复"允许 / 同意 / yes" 后**重调本 tool**;用户**明确拒绝**告诉用户已取消 + - 不重试;用户**含糊 / 犹豫 / 跳话题**也不要重调,主动追问明确再决定,**沉默不是同意**); - **promote 只是状态切换,候选不会自己跑**——要让它按行情自动跑必须再调 paper.start_strategy - -**模拟盘 live runner(D-11 · issue #1)**: +- paper.promote_and_start_strategy —— 当用户明确要求“投入模拟盘自动跑”且候选仍是 candidate 时使用。 + permission='ask':第一次返 requiresApproval;用户一次明确同意后,以相同执行参数重调,工具会 + 连续完成转正和 runner 启动,**不再询问第二次确认**。审批覆盖 candidate、venue、symbol、周期、 + 参数、模式、杠杆与额度;reason 是审计文案,不影响确认匹配。 +- paper.promote_candidate —— 仅把候选从 'candidate' 切到 'promoted'(单独转正时使用, + permission='ask',用户明确同意后重调本 tool);它本身不会启动 runner。 - paper.start_strategy —— 把**已 promoted** 的候选放到模拟盘按行情自动跑(后台 runner 拉 bar 喂 on_bar → 走护栏内 plan/exec 下单 → 持仓 / 权益自动更新)。需指定 symbol / - timeframe(candidate 表不含)。**关键**:promote 成功后主动告诉用户"还需 start_strategy - 才会真跑",**不要 promote 完默认自动起**——start 是独立的人工动作。同 candidate 同时只一个 running。 + timeframe;已转正候选的后续独立启动走本工具。 - paper.stop_strategy —— 按 runId 停一个 live runner - paper.list_strategy_runs —— 列 live runner 状态 / 累计 pnl / 错误日志 diff --git a/packages/orchestration/src/permissions/approval-identity.ts b/packages/orchestration/src/permissions/approval-identity.ts index a8859f2f..b590d0e7 100644 --- a/packages/orchestration/src/permissions/approval-identity.ts +++ b/packages/orchestration/src/permissions/approval-identity.ts @@ -44,6 +44,18 @@ export const APPROVAL_IDENTITY_FIELDS: Readonly { + const tc = ctx?.requestContext as ToolRequestContext | undefined; + const client = await getClient(tc); + const candidate = await client.getCandidate(inputData.candidateId); + if (candidate.status !== "candidate" && candidate.status !== "promoted") { + throw new Error( + `candidate ${inputData.candidateId} is '${candidate.status}', must be 'candidate' or 'promoted' to start a strategy`, + ); + } + const promotedNow = candidate.status === "candidate"; + const promoted = promotedNow + ? await client.promoteCandidate(inputData.candidateId, inputData.reason) + : candidate; + const run = await client.startStrategy({ + candidateId: inputData.candidateId, + venue: inputData.venue, + symbol: inputData.symbol, + timeframe: inputData.timeframe, + params: inputData.params, + tradingMode: inputData.tradingMode, + leverage: inputData.leverage, + allocation: inputData.allocation, + }); + return { promotedNow, candidate: promoted, run }; + }, +}); + export const paperStartStrategyTool = createTool({ id: "paper.start_strategy", description: ` diff --git a/packages/orchestration/tests/approval-identity.test.ts b/packages/orchestration/tests/approval-identity.test.ts index decc546d..7158375e 100644 --- a/packages/orchestration/tests/approval-identity.test.ts +++ b/packages/orchestration/tests/approval-identity.test.ts @@ -23,6 +23,35 @@ describe("projectApprovalInput", () => { expect(a).not.toEqual(b); }); + it("模拟盘复合启动忽略审计 reason,但绑定完整执行范围", () => { + const base = { + candidateId: "c-42", + venue: "binance", + symbol: "BTC/USDT", + timeframe: "1h", + params: { fast: 10 }, + tradingMode: "spot", + leverage: 1, + allocation: 5000, + }; + const first = projectApprovalInput("paper.promote_and_start_strategy", { + ...base, + reason: "第一次审计措辞", + }); + const rewrittenReason = projectApprovalInput("paper.promote_and_start_strategy", { + ...base, + reason: "确认后改写的审计措辞", + }); + const changedAllocation = projectApprovalInput("paper.promote_and_start_strategy", { + ...base, + allocation: 8000, + reason: "同一审计措辞", + }); + + expect(first).toEqual(rewrittenReason); + expect(first).not.toEqual(changedAllocation); + }); + it("未登记 tool:原样返回完整 input", () => { const input = { foo: 1, bar: "z" }; expect(projectApprovalInput("some.other_tool", input)).toBe(input); diff --git a/packages/orchestration/tests/ask-path-e2e.test.ts b/packages/orchestration/tests/ask-path-e2e.test.ts index b9bf4d0d..155f16bb 100644 --- a/packages/orchestration/tests/ask-path-e2e.test.ts +++ b/packages/orchestration/tests/ask-path-e2e.test.ts @@ -159,6 +159,50 @@ describe("ask-path e2e · 审批身份投影(promote reason 措辞变化不重 }); }); +describe("ask-path e2e · 复合模拟盘启动只确认一次", () => { + it("首次 ask 后,确认重调即执行转正和 runner 启动,不再返回第二次 requiresApproval", async () => { + const { events, cache, store, runner } = makeEnv(); + const exec = vi.fn().mockResolvedValue({ + promotedNow: true, + candidate: { status: "promoted" }, + run: { status: "running" }, + }); + const tool = { id: "paper.promote_and_start_strategy", description: "", execute: exec }; + const wrapped = withHooks(tool, { + runner, + permissionResolver: () => "ask", + askCache: cache, + pendingApprovals: store, + getSessionId: () => "thread-A", + }); + const input = { + candidateId: "c-42", + reason: "首次审计说明,包含回测与风控指标", + venue: "binance", + symbol: "BTC/USDT", + timeframe: "1h", + params: { fast: 10 }, + tradingMode: "spot", + leverage: 1, + allocation: 5000, + }; + + const first = (await wrapped.execute!(input)) as { requiresApproval: boolean }; + expect(first.requiresApproval).toBe(true); + expect(exec).not.toHaveBeenCalled(); + + const second = await wrapped.execute!({ ...input, reason: "确认后改写审计文案,不改变运行范围" }); + expect(second).toMatchObject({ promotedNow: true, run: { status: "running" } }); + expect(exec).toHaveBeenCalledOnce(); + expect(eventsOf(events, "ask_marked")).toHaveLength(1); + expect(eventsOf(events, "ask_consumed")).toHaveLength(1); + + store.clearAll(); + cache.clear(); + }); +}); + + describe("ask-path e2e · 跨 sessionId 不复用", () => { it("A 用户 mark 不被 B 用户 consume;两条独立 entry", async () => { const { events, cache, store, runner } = makeEnv(); diff --git a/packages/orchestration/tests/tools.test.ts b/packages/orchestration/tests/tools.test.ts index a3e3b7ed..4cba363e 100644 --- a/packages/orchestration/tests/tools.test.ts +++ b/packages/orchestration/tests/tools.test.ts @@ -19,6 +19,7 @@ import { paperListStrategyRunDecisionsTool, paperListStrategyRunsTool, paperRunBacktestTool, + paperPromoteAndStartStrategyTool, paperStartStrategyTool, paperStopStrategyTool, researchDeepDiveTool, @@ -874,7 +875,122 @@ describe("factor.timing / score / catalog", () => { // D-11 · live runner tools // ──────────────────────────────────────────────────────────────────── -describe("paper.start_strategy / stop / list", () => { +describe("paper.promote_and_start_strategy / start_strategy / stop / list", () => { + const candidateId = "550e8400-e29b-41d4-a716-446655440000"; + const candidate = { + id: candidateId, + code: "class X(Strategy): pass", + code_hash: "abc", + description: "x", + author: "llm", + author_id: null, + status: "candidate", + metrics: { sharpe: 1.5 }, + fitness: 0.85, + last_backtest_run_id: null, + audit: { ok: true }, + created_at: "2026-05-25T00:00:00Z", + updated_at: "2026-05-25T00:00:00Z", + }; + const runningRun = { + id: "run-1", candidate_id: candidateId, account_id: "acc-1", status: "running", + venue: "binance", symbol: "BTC/USDT", timeframe: "1h", params: { fast: 10 }, + last_bar_ts: null, cumulative_pnl: 0, error_log: [], + started_at: "2026-06-02T00:00:00Z", stopped_at: null, + }; + + it("uses one tool call to promote then start the requested runner", async () => { + const calls: Array<{ url: string; body: Record }> = []; + mockFetch(async (url, init) => { + calls.push({ url, body: JSON.parse((init?.body as string) ?? "{}") }); + if (url.includes(`/strategy_candidates/${candidateId}`) && !url.includes("promote")) { + return new Response(JSON.stringify(candidate), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.includes("/promote")) { + return new Response(JSON.stringify({ ...candidate, status: "promoted" }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response(JSON.stringify(runningRun), { status: 200, headers: { "Content-Type": "application/json" } }); + }); + + const result = await paperPromoteAndStartStrategyTool.execute!({ + candidateId, + reason: "2026-Q2 BTC 1h fitness=0.85 vs baseline=0.32, drawdown<10%", + venue: "binance", + symbol: "BTC/USDT", + timeframe: "1h", + params: { fast: 10 }, + allocation: 5000, + } as never, ctx()) as { promotedNow: boolean; run: { status: string } }; + + expect(calls.map(({ url }) => url)).toEqual([ + expect.stringContaining(`/strategy_candidates/${candidateId}`), + expect.stringContaining(`/strategy_candidates/${candidateId}/promote`), + expect.stringContaining("/strategy_runs"), + ]); + expect(calls[2].body).toMatchObject({ + candidate_id: candidateId, venue: "binance", symbol: "BTC/USDT", timeframe: "1h", + params: { fast: 10 }, allocation: 5000, + }); + expect(result).toMatchObject({ promotedNow: true, run: { status: "running" } }); + }); + + it("skips promotion for an already promoted candidate", async () => { + const urls: string[] = []; + mockFetch(async (url) => { + urls.push(url); + if (url.includes(`/strategy_candidates/${candidateId}`)) { + return new Response(JSON.stringify({ ...candidate, status: "promoted" }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response(JSON.stringify(runningRun), { status: 200, headers: { "Content-Type": "application/json" } }); + }); + + const result = await paperPromoteAndStartStrategyTool.execute!({ + candidateId, + reason: "2026-Q2 BTC 1h fitness=0.85 vs baseline=0.32, drawdown<10%", + venue: "binance", symbol: "BTC/USDT", timeframe: "1h", + } as never, ctx()) as { promotedNow: boolean; run: { status: string } }; + + expect(urls).toHaveLength(2); + expect(urls.some((url) => url.includes("/promote"))).toBe(false); + expect(result).toMatchObject({ promotedNow: false, run: { status: "running" } }); + }); + + it("rejects a candidate that cannot be promoted before creating a runner", async () => { + const urls: string[] = []; + mockFetch(async (url) => { + urls.push(url); + return new Response(JSON.stringify({ ...candidate, status: "rejected" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await expect(paperPromoteAndStartStrategyTool.execute!({ + candidateId, + reason: "2026-Q2 BTC 1h fitness=0.85 vs baseline=0.32, drawdown<10%", + venue: "binance", symbol: "BTC/USDT", timeframe: "1h", + } as never, ctx())).rejects.toThrow("must be 'candidate' or 'promoted'"); + expect(urls).toHaveLength(1); + }); + + it("keeps a successful promotion visible when runner startup fails", async () => { + mockFetch(async (url) => { + if (url.includes(`/strategy_candidates/${candidateId}`) && !url.includes("promote")) { + return new Response(JSON.stringify(candidate), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.includes("/promote")) { + return new Response(JSON.stringify({ ...candidate, status: "promoted" }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response(JSON.stringify({ code: "SYMBOL_RUN_CONFLICT", message: "runner exists", details: {} }), { status: 409, headers: { "Content-Type": "application/json" } }); + }); + + await expect(paperPromoteAndStartStrategyTool.execute!({ + candidateId, + reason: "2026-Q2 BTC 1h fitness=0.85 vs baseline=0.32, drawdown<10%", + venue: "binance", symbol: "BTC/USDT", timeframe: "1h", + } as never, ctx())).rejects.toMatchObject({ code: "SYMBOL_RUN_CONFLICT", status: 409 }); + }); + it("start_strategy POSTs to /strategy_runs with candidate_id + market", async () => { let capturedUrl = ""; let capturedBody = ""; From b177c4aaf77255d1d5a7297fc08cafee2acfed83 Mon Sep 17 00:00:00 2001 From: Miro Date: Tue, 28 Jul 2026 10:21:20 +0800 Subject: [PATCH 09/11] =?UTF-8?q?fix(selfhost):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E7=83=9F=E6=B5=8B=E4=B8=8E=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 35 +++++++++-- .../src/components/llm/LLMConfigGate.tsx | 11 +++- packages/orchestration/tests/identity.test.ts | 58 +++++++++++++++++++ scripts/selfhost.sh | 2 +- 4 files changed, 98 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aabc34bc..f8f20942 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,13 +24,40 @@ jobs: run: bash scripts/check-consistency.sh - name: Validate self-host Compose run: | - cp infra/.env.selfhost.example infra/.env.selfhost - for key in POSTGRES_PASSWORD REDIS_PASSWORD JWT_SECRET LLM_CONFIG_ENCRYPTION_KEY; do - sed -i "s/^${key}=.*/${key}=ci-test-secret/" infra/.env.selfhost - done + bash scripts/selfhost.sh init + test "$(stat --format=%a infra/.env.selfhost)" = "600" + ! grep -Eq '__[A-Z_]+__' infra/.env.selfhost ENV_FILE=.env.selfhost docker compose -f infra/docker-compose.prod.yml -f infra/docker-compose.selfhost.yml --env-file infra/.env.selfhost config --quiet rm infra/.env.selfhost + selfhost-smoke: + name: self-host · build + health smoke + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Initialize self-host environment + run: | + bash scripts/selfhost.sh init + test "$(stat --format=%a infra/.env.selfhost)" = "600" + ! grep -Eq '__[A-Z_]+__' infra/.env.selfhost + - name: Build and start self-host stack + run: | + docker compose -f infra/docker-compose.prod.yml -f infra/docker-compose.selfhost.yml --env-file infra/.env.selfhost build + docker compose -f infra/docker-compose.prod.yml -f infra/docker-compose.selfhost.yml --env-file infra/.env.selfhost up -d --wait --wait-timeout 600 + - name: Create self-host user + run: | + printf 'smoke-password' | docker compose -f infra/docker-compose.prod.yml -f infra/docker-compose.selfhost.yml --env-file infra/.env.selfhost run --rm -T paper \ + uv run python scripts/create_user.py --email smoke@example.invalid --subject console:smoke --password-stdin + - name: Diagnose failed self-host smoke + if: failure() + run: | + docker compose -f infra/docker-compose.prod.yml -f infra/docker-compose.selfhost.yml --env-file infra/.env.selfhost ps + docker compose -f infra/docker-compose.prod.yml -f infra/docker-compose.selfhost.yml --env-file infra/.env.selfhost logs + - name: Remove self-host stack + if: always() + run: docker compose -f infra/docker-compose.prod.yml -f infra/docker-compose.selfhost.yml --env-file infra/.env.selfhost down -v --remove-orphans + orchestration-typecheck: name: orchestration · typecheck + test runs-on: ubuntu-latest diff --git a/apps/dashboard/src/components/llm/LLMConfigGate.tsx b/apps/dashboard/src/components/llm/LLMConfigGate.tsx index c7ecd054..9950dd8b 100644 --- a/apps/dashboard/src/components/llm/LLMConfigGate.tsx +++ b/apps/dashboard/src/components/llm/LLMConfigGate.tsx @@ -14,17 +14,22 @@ export function LLMConfigGate() { const openSettings = () => setOpen(true); window.addEventListener("inalpha:open-llm-settings", openSettings); + let mounted = true; if (!localStorage.getItem(LS_DISMISSED)) { void fetch("/api/user/settings") .then((response) => response.ok ? response.json() : null) .then((settings) => { - if (settings && (!settings.configs || settings.configs.length === 0)) { + if (mounted && settings && (!settings.configs || settings.configs.length === 0)) { setOpen(true); } - }); + }) + .catch(() => {}); } - return () => window.removeEventListener("inalpha:open-llm-settings", openSettings); + return () => { + mounted = false; + window.removeEventListener("inalpha:open-llm-settings", openSettings); + }; }, []); return ( diff --git a/packages/orchestration/tests/identity.test.ts b/packages/orchestration/tests/identity.test.ts index 2a8b5f05..816dc7c1 100644 --- a/packages/orchestration/tests/identity.test.ts +++ b/packages/orchestration/tests/identity.test.ts @@ -1,12 +1,27 @@ import { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { mintServiceToken } from "../src/auth.js"; +import { AUTH_SUB_KEY } from "../src/hooks/with-hooks.js"; import { identityMiddleware } from "../src/mastra/identity.js"; const TEST_KEY = "unique-test-key-must-never-reach-logs"; +function appWithRequestContext(requestContext: Map) { + const app = new Hono(); + app.use("*", async (context, next) => { + (context.set as (key: string, value: unknown) => void)("requestContext", requestContext); + await next(); + }); + app.use("*", identityMiddleware); + app.get("/", (context) => context.text("ok")); + return app; +} + afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.resetModules(); }); describe("identityMiddleware", () => { @@ -37,4 +52,47 @@ describe("identityMiddleware", () => { model: "claude-test", }); }); + + it("injects the verified Bearer subject into request context", async () => { + const requestContext = new Map(); + const response = await appWithRequestContext(requestContext).request("/", { + headers: { Authorization: `Bearer ${await mintServiceToken({ sub: "user:alice" })}` }, + }); + + expect(response.status).toBe(200); + expect(requestContext.get(AUTH_SUB_KEY)).toBe("user:alice"); + }); + + it("does not inject a subject for an invalid Bearer token", async () => { + const requestContext = new Map(); + const response = await appWithRequestContext(requestContext).request("/", { + headers: { Authorization: "Bearer invalid-token" }, + }); + + expect(response.status).toBe(200); + expect(requestContext.has(AUTH_SUB_KEY)).toBe(false); + }); +}); + +describe("buildUserAwareModel", () => { + it("requires a user LLM configuration when authentication is enabled", async () => { + vi.stubEnv("AUTH_ENABLED", "true"); + vi.resetModules(); + const { buildUserAwareModel } = await import("../src/mastra/llm/provider.js"); + + expect(() => buildUserAwareModel().doGenerate).toThrow("用户未配置 LLM API Key"); + }); + + it("uses an ALS-scoped user configuration when authentication is enabled", async () => { + vi.stubEnv("AUTH_ENABLED", "true"); + vi.resetModules(); + const { buildUserAwareModel, userLLMStore } = await import("../src/mastra/llm/provider.js"); + const model = buildUserAwareModel(); + + expect(() => userLLMStore.run({ + id: "config-1", + provider: "anthropic", + api_key: "user-key", + }, () => model.doGenerate)).not.toThrow(); + }); }); diff --git a/scripts/selfhost.sh b/scripts/selfhost.sh index 4a9f6628..89f780b5 100755 --- a/scripts/selfhost.sh +++ b/scripts/selfhost.sh @@ -105,7 +105,7 @@ case "$command" in ;; up) require_env - "${COMPOSE[@]}" up -d --build + "${COMPOSE[@]}" up -d --build --wait --wait-timeout 600 "${COMPOSE[@]}" ps ;; down) From 9a92b47103612677f16b31f7275ebcdf665cb748 Mon Sep 17 00:00:00 2001 From: Miro Date: Tue, 28 Jul 2026 10:32:07 +0800 Subject: [PATCH 10/11] =?UTF-8?q?fix(infra):=20=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=20Mastra=20=E6=95=B0=E6=8D=AE=E5=8D=B7=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- infra/docker/Dockerfile.mastra | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/infra/docker/Dockerfile.mastra b/infra/docker/Dockerfile.mastra index b27850e5..e6e1647d 100644 --- a/infra/docker/Dockerfile.mastra +++ b/infra/docker/Dockerfile.mastra @@ -39,4 +39,5 @@ ENV NODE_ENV=production RUN pnpm exec mastra build --dir src/mastra EXPOSE 4111 -CMD ["node", ".mastra/output/index.mjs"] +USER root +CMD ["sh", "-c", "mkdir -p /app/.data /app/.data-backups && chown -R node:node /app/.data /app/.data-backups && exec su node -s /bin/sh -c 'node .mastra/output/index.mjs'"] From a0b9fe64acff451d354ad33b61c2fe252c743fb3 Mon Sep 17 00:00:00 2001 From: Miro Date: Tue, 28 Jul 2026 10:54:09 +0800 Subject: [PATCH 11/11] =?UTF-8?q?fix(ci):=20=E5=AF=B9=E9=BD=90=20Web=20?= =?UTF-8?q?=E5=BF=85=E9=9C=80=E6=A3=80=E6=9F=A5=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8f20942..d68e800b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: run: pnpm test web-typecheck: - name: web · typecheck + test + build + name: web · typecheck + build runs-on: ubuntu-latest defaults: run: