From ba45382747ef0a99ff8fb9e2b8db2f308d88f358 Mon Sep 17 00:00:00 2001 From: Wenxin Duan Date: Tue, 8 Sep 2026 11:12:12 +0800 Subject: [PATCH] fix: close browser session lifecycle gaps --- README.md | 6 +-- apps/server/src/errors/error-handler.ts | 3 ++ apps/server/tests/api.test.ts | 17 +++++++++ apps/web/src/App.tsx | 13 ++++++- apps/web/src/api/harness-api.ts | 2 +- .../src/components/settings/ModelSettings.tsx | 8 ++-- apps/web/src/store/harness-client-store.ts | 1 + apps/web/src/styles/layout.css | 2 +- apps/web/tests/api.test.ts | 34 +++++++++++++++++ apps/web/tests/app-shell.test.tsx | 38 ++++++++++++++++++- apps/web/tests/client-store.test.ts | 34 +++++++++++++++++ apps/web/tests/settings.test.tsx | 2 + 12 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 apps/web/tests/api.test.ts create mode 100644 apps/web/tests/client-store.test.ts diff --git a/README.md b/README.md index 18eeee1..0d5796a 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ Anthropic 使用 `AGENT_PROVIDER=anthropic`、`LLM_API=anthropic-messages` 和 ` 也可以在页面右上角打开 Settings,填写 Provider、API 类型、Base URL、API Key、Model 和图片能力。保存后配置只属于当前 Session。连接测试在当前实现中安全地返回“首次运行验证”,不会编造一次真实调用成功。 -API Key 从同源请求提交后只驻留服务器进程内存,并在 Pi `InMemoryCredentialStore` 中注入;不会写入 localStorage、sessionStorage、IndexedDB、SSE、事件、响应或用户目录。GET 只返回脱敏的 `keyHint`。刷新页面会新建 Session;删除 Session、清除凭证或重启进程都会丢失该内存配置。 +API Key 从同源请求提交后只驻留服务器进程内存,并在 Pi `InMemoryCredentialStore` 中注入;不会写入 localStorage、sessionStorage、IndexedDB、SSE、事件、响应或用户目录。GET 只返回脱敏的 `keyHint`。使用页面“重置训练会话”、清除凭证或重启进程会清除对应内存配置。 ## 图片链路 @@ -139,7 +139,7 @@ pnpm test pnpm build ``` -最近一次完整测试包含 117 条测试:shared 13、harness 68、server 16、web 20。默认测试使用可编程 Fake AgentSession,不需要真实 API Key、真实模型或外网。最终交付前还会重新运行以上四个命令并以实际结果为准。 +最近一次完整测试包含 123 条测试:shared 13、harness 68、server 17、web 25。默认测试使用可编程 Fake AgentSession,不需要真实 API Key、真实模型或外网。最终交付前还会重新运行以上四个命令并以实际结果为准。 ## 当前依赖与 API 核对 @@ -153,6 +153,6 @@ pnpm build ## 已知限制与生产化 -课堂版是单进程、单实例、内存 EventStore / ApprovalBroker / ModelConnectionStore;刷新会创建新 Session,进程退出不会恢复运行,报告和附件只是本机临时文件。它没有用户身份、跨设备同步、分布式事件总线或操作系统级隔离。 +课堂版是单进程、单实例、内存 EventStore / ApprovalBroker / ModelConnectionStore;进程退出不会恢复运行,报告和附件只是本机临时文件。浏览器直接刷新会丢失旧 Session 的 UI 引用,旧 Session 要到 Server 重启时统一清理,因此正常操作应使用页面“重置训练会话”。它没有用户身份、跨设备同步、分布式事件总线或操作系统级隔离。 生产化需要 Redis/Postgres 持久化、对象存储、KMS/Secret Manager、身份鉴权、授权与租户隔离、CSRF 和速率限制、容器或 microVM Sandbox、出站网络策略、不可篡改审计日志、分布式锁与可重放 SSE。路线见 [docs/production-roadmap.md](docs/production-roadmap.md)。 diff --git a/apps/server/src/errors/error-handler.ts b/apps/server/src/errors/error-handler.ts index e403634..46623f5 100644 --- a/apps/server/src/errors/error-handler.ts +++ b/apps/server/src/errors/error-handler.ts @@ -40,6 +40,9 @@ function mapError(error: FastifyError | Error): { if ("statusCode" in error && error.statusCode === 413) { return { statusCode: 413, code: "PAYLOAD_TOO_LARGE", message: "Request payload is too large", details: {} }; } + if ("statusCode" in error && error.statusCode === 400) { + return { statusCode: 400, code: "INVALID_REQUEST_BODY", message: "Request body could not be processed", details: {} }; + } return { statusCode: 500, code: "INTERNAL_ERROR", message: "Internal server error", details: {} }; } diff --git a/apps/server/tests/api.test.ts b/apps/server/tests/api.test.ts index 55da734..b2682ac 100644 --- a/apps/server/tests/api.test.ts +++ b/apps/server/tests/api.test.ts @@ -53,4 +53,21 @@ describe("HTTP API", () => { expect(busy.statusCode).toBe(409); expect(busy.body).not.toContain("stack"); }); + + it("keeps Fastify body parse errors as safe client errors", async () => { + const { app } = createTestApp(); + apps.push(app); + const response = await app.inject({ + method: "POST", + url: "/api/sessions", + headers: { "content-type": "application/json" }, + payload: "", + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + error: { code: "INVALID_REQUEST_BODY", message: "Request body could not be processed" }, + }); + expect(response.body).not.toContain("stack"); + }); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 64575fd..82beb5d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -14,6 +14,7 @@ import { HarnessClientStore } from "./store/harness-client-store.js"; type AppStore = HarnessRuntimeStore & { start(): Promise; + dispose?: () => Promise; getModelConfig(): Promise<{ modelConfig: ModelView }>; putModelConfig?: (input: ModelConnectionInput) => Promise<{ modelConfig: ModelView }>; testModelConfig?: () => Promise<{ status: "ok" | "deferred"; message: string }>; @@ -87,7 +88,17 @@ export function App({ store: suppliedStore, autoStart = true, modelConfigured: s modelLabel: model.modelId ?? "No model", supportsImages: model.supportsImages ?? false, onOpenSettings: () => setSettingsOpen(true), - onReset: () => window.location.reload(), + onReset: () => { + if (store.dispose === undefined) { + window.location.reload(); + return; + } + void store.dispose() + .then(() => store.start()) + .then(() => store.getModelConfig()) + .then((response) => setModel(response.modelConfig)) + .catch(() => undefined); + }, }} skills={} chat={

密钥仅发送到本机服务并保存在当前会话内存中,不会写入浏览器存储。

-
void submit(event)}> + void submit(event)}> - - + + - + {saved?.keyHint === undefined ? null :

服务器中的密钥:{saved.keyHint}

} {message === "" ? null :

{message}

} {busy ?

运行期间不能更改模型配置

: null} diff --git a/apps/web/src/store/harness-client-store.ts b/apps/web/src/store/harness-client-store.ts index bf9aa34..846c58e 100644 --- a/apps/web/src/store/harness-client-store.ts +++ b/apps/web/src/store/harness-client-store.ts @@ -38,6 +38,7 @@ export class HarnessClientStore { public async dispose(): Promise { this.#eventSource?.close(); this.#eventSource = undefined; + this.#starting = undefined; const sessionId = this.#state.sessionId; if (sessionId !== undefined) await this.api.deleteSession(sessionId); this.#setState(createInitialHarnessState()); diff --git a/apps/web/src/styles/layout.css b/apps/web/src/styles/layout.css index 2e79c91..222b3af 100644 --- a/apps/web/src/styles/layout.css +++ b/apps/web/src/styles/layout.css @@ -6,7 +6,7 @@ .side-panel { display: flex; flex-direction: column; overflow: hidden; background: var(--surface); } .chat-panel { background: var(--bg); } .mobile-tabs { display: none; } -@media (max-width: 1080px) { +@media (min-width: 721px) and (max-width: 1080px) { .workspace-grid { grid-template-columns: minmax(210px, 240px) minmax(400px, 1fr); } .workspace-grid > div:last-child { display: none; } } diff --git a/apps/web/tests/api.test.ts b/apps/web/tests/api.test.ts new file mode 100644 index 0000000..64578fd --- /dev/null +++ b/apps/web/tests/api.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { HarnessApi } from "../src/api/harness-api.js"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("HarnessApi request headers", () => { + it("does not claim an empty POST contains JSON", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + sessionId: "ses_1", + requestId: "req_1", + }), { status: 201, headers: { "Content-Type": "application/json" } })); + vi.stubGlobal("fetch", fetchMock); + + await new HarnessApi().createSession(); + + const request = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(new Headers(request.headers).has("Content-Type")).toBe(false); + expect(request.body).toBeUndefined(); + }); + + it("sets JSON content type when a structured body is present", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + runId: "run_1", + requestId: "req_1", + }), { status: 202, headers: { "Content-Type": "application/json" } })); + vi.stubGlobal("fetch", fetchMock); + + await new HarnessApi().sendMessage("ses_1", { text: "hello", attachmentIds: [] }); + + const request = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(new Headers(request.headers).get("Content-Type")).toBe("application/json"); + expect(request.body).toBe(JSON.stringify({ text: "hello", attachmentIds: [] })); + }); +}); diff --git a/apps/web/tests/app-shell.test.tsx b/apps/web/tests/app-shell.test.tsx index 7e60302..38eb6f3 100644 --- a/apps/web/tests/app-shell.test.tsx +++ b/apps/web/tests/app-shell.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom/vitest"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { App } from "../src/App.js"; import { createInitialHarnessState, type HarnessClientState } from "../src/store/reducer.js"; @@ -24,6 +24,7 @@ function makeStore() { subscribe: () => () => undefined, sendMessage, cancel: vi.fn().mockResolvedValue({ cancelled: false }), + dispose: vi.fn().mockResolvedValue(undefined), start: vi.fn().mockResolvedValue(undefined), getModelConfig: vi.fn().mockResolvedValue({ modelConfig: { configured: true, source: "environment", providerId: "openai", api: "openai-responses", modelId: "gpt-test", supportsImages: true }, @@ -31,6 +32,8 @@ function makeStore() { }; } +afterEach(() => vi.unstubAllGlobals()); + describe("App", () => { it("renders the three primary work areas", () => { render(); @@ -55,4 +58,37 @@ describe("App", () => { expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled(); expect(screen.getByText("请先配置模型连接")).toBeVisible(); }); + + it("switches to the event timeline in the mobile tab layout", async () => { + vi.stubGlobal("matchMedia", vi.fn(() => ({ + matches: true, + media: "(max-width: 720px)", + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }))); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("tab", { name: "Events" })); + + expect(screen.getByRole("tab", { name: "Events" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("region", { name: "Event Timeline" })).toBeVisible(); + expect(screen.queryByRole("main", { name: "Streaming Chat" })).not.toBeInTheDocument(); + }); + + it("deletes the old session before starting a reset session", async () => { + const user = userEvent.setup(); + const store = makeStore(); + render(); + + await user.click(screen.getByRole("button", { name: "重置训练会话" })); + + expect(store.dispose).toHaveBeenCalledTimes(1); + expect(store.start).toHaveBeenCalledTimes(1); + expect(store.dispose.mock.invocationCallOrder[0]).toBeLessThan(store.start.mock.invocationCallOrder[0] ?? 0); + }); }); diff --git a/apps/web/tests/client-store.test.ts b/apps/web/tests/client-store.test.ts new file mode 100644 index 0000000..2c5817f --- /dev/null +++ b/apps/web/tests/client-store.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import type { HarnessApi } from "../src/api/harness-api.js"; +import { HarnessClientStore } from "../src/store/harness-client-store.js"; + +describe("HarnessClientStore lifecycle", () => { + it("deletes the current session and can start a fresh session after dispose", async () => { + const createSession = vi.fn() + .mockResolvedValueOnce({ sessionId: "ses_1", requestId: "req_1" }) + .mockResolvedValueOnce({ sessionId: "ses_2", requestId: "req_2" }); + const deleteSession = vi.fn().mockResolvedValue({ deleted: true, requestId: "req_delete" }); + const api = { + createSession, + deleteSession, + getSkills: vi.fn().mockResolvedValue({ skills: [], requestId: "req_skills" }), + eventsUrl: (sessionId: string) => `/api/sessions/${sessionId}/events`, + } as unknown as HarnessApi; + const eventSources: Array<{ close: ReturnType }> = []; + const store = new HarnessClientStore(api, () => { + const source = { addEventListener: vi.fn(), close: vi.fn() }; + eventSources.push(source); + return source; + }); + + await store.start(); + expect(store.getSnapshot().sessionId).toBe("ses_1"); + await store.dispose(); + await store.start(); + + expect(deleteSession).toHaveBeenCalledWith("ses_1"); + expect(createSession).toHaveBeenCalledTimes(2); + expect(eventSources[0]?.close).toHaveBeenCalledTimes(1); + expect(store.getSnapshot().sessionId).toBe("ses_2"); + }); +}); diff --git a/apps/web/tests/settings.test.tsx b/apps/web/tests/settings.test.tsx index 2fa5207..6c27b22 100644 --- a/apps/web/tests/settings.test.tsx +++ b/apps/web/tests/settings.test.tsx @@ -10,6 +10,8 @@ describe("ModelSettings", () => { const save = vi.fn().mockResolvedValue({ configured: true, source: "session", keyHint: "••••cret" }); render(); + expect(screen.getByLabelText("模型 ID")).toHaveAttribute("autocomplete", "off"); + expect(screen.getByLabelText("API Key")).toHaveAttribute("autocomplete", "new-password"); await user.type(screen.getByLabelText("API Key"), "sk-browser-secret"); await user.type(screen.getByLabelText("模型 ID"), "gpt-test"); await user.click(screen.getByRole("button", { name: "保存配置" }));