From 0fbaf7d76a2a84894da0e0eda05fde396059df00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=B2=A9=E5=B2=A9?= Date: Fri, 14 Aug 2026 16:21:09 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=8F=91=E5=B8=83=E5=89=8D=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E4=B8=8E=E8=BE=B9=E7=95=8C=E5=8A=A0=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ai-project/state.yaml | 6 +- .../src/composition/create-app.test.ts | 10 ++++ apps/web-server/src/composition/create-app.ts | 14 +++++ .../src/composition/run-registry.test.ts | 29 +++++++++ .../src/composition/run-registry.ts | 8 +++ apps/web-server/src/index.ts | 4 +- apps/web-server/src/routes/runs.ts | 1 + apps/web/src/app/app-controller.ts | 8 ++- docs/FEATURES.md | 13 +++- package.json | 1 + .../src/use-cases/agent-run-service.test.ts | 16 +++++ .../src/use-cases/agent-run-service.ts | 48 ++++++++------- .../src/use-cases/interaction-run.test.ts | 4 ++ .../src/use-cases/interaction-service.ts | 4 ++ .../src/use-cases/run-service-helpers.ts | 59 ++++++++++++++++++- .../file-conversation-repository.test.ts | 4 +- .../file-conversation-repository.ts | 20 +++++-- .../file-interaction-repository.ts | 19 ++++-- .../repositories/file-project-repository.ts | 20 ++++--- .../repositories/file-run-repository.test.ts | 7 ++- .../src/repositories/file-run-repository.ts | 24 ++++++-- scripts/check-secrets.ts | 23 ++++++++ 22 files changed, 279 insertions(+), 63 deletions(-) create mode 100644 apps/web-server/src/composition/run-registry.test.ts create mode 100644 scripts/check-secrets.ts diff --git a/.ai-project/state.yaml b/.ai-project/state.yaml index 830fb61..294bedb 100644 --- a/.ai-project/state.yaml +++ b/.ai-project/state.yaml @@ -1,7 +1,7 @@ 工作流: "idea-to-product" 项目: "Great Agent 2" 版本: "0.3.0" -当前阶段: "纵向功能开发" +当前阶段: "功能验收" 更新时间: "2026-08-14" 阻塞: null @@ -19,7 +19,7 @@ 确认人: "用户" 确认时间: "2026-08-12" 功能验收: - 状态: "未开始" + 状态: "待确认" 确认人: null 确认时间: null @@ -53,4 +53,4 @@ 状态: "已完成" - 编号: "F-010" 名称: "恢复、边界与发布前加固" - 状态: "进行中" + 状态: "已完成" diff --git a/apps/web-server/src/composition/create-app.test.ts b/apps/web-server/src/composition/create-app.test.ts index d6e7574..cf5cbda 100644 --- a/apps/web-server/src/composition/create-app.test.ts +++ b/apps/web-server/src/composition/create-app.test.ts @@ -11,4 +11,14 @@ describe("Web Server 骨架", () => { expect(response.headers.get("X-Request-ID")).toBeTruthy(); expect(healthResponseSchema.parse(await response.json()).status).toBe("ok"); }); + + test("拒绝超过 5 MiB 的 API 请求体", async () => { + const app = createApp(pino({ enabled: false })); + const response = await app.request("/api/oversized", { + method: "POST", + body: "x".repeat(5 * 1024 * 1024 + 1), + }); + expect(response.status).toBe(413); + expect((await response.json()).error.code).toBe("REQUEST_TOO_LARGE"); + }); }); diff --git a/apps/web-server/src/composition/create-app.ts b/apps/web-server/src/composition/create-app.ts index 1b2657d..e238ce9 100644 --- a/apps/web-server/src/composition/create-app.ts +++ b/apps/web-server/src/composition/create-app.ts @@ -1,4 +1,5 @@ import { Hono } from "hono"; +import { bodyLimit } from "hono/body-limit"; import type { Logger } from "pino"; import { createErrorHandler } from "../http/error-handler"; import { requestId } from "../http/request-id"; @@ -31,6 +32,19 @@ export function createApp( ): Hono { const app = new Hono(); app.use("*", requestId); + app.use( + "/api/*", + bodyLimit({ + maxSize: 5 * 1024 * 1024, + onError: (context) => + context.json( + { + error: { code: "REQUEST_TOO_LARGE", message: "请求内容超过 5 MiB" }, + }, + 413, + ), + }), + ); app.onError(createErrorHandler(logger)); app.route("/api", createHealthRoutes()); if (conversations) diff --git a/apps/web-server/src/composition/run-registry.test.ts b/apps/web-server/src/composition/run-registry.test.ts new file mode 100644 index 0000000..e7c0a45 --- /dev/null +++ b/apps/web-server/src/composition/run-registry.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import type { RunEvent } from "@great-agent/agent-core"; +import { RunRegistry } from "./run-registry"; + +describe("RunRegistry", () => { + test("重启后补入持久化事件并去重终态", async () => { + const registry = new RunRegistry(); + const events: RunEvent[] = [ + { + runId: "run_1", + sequence: 1, + timestamp: "2026-08-14T00:00:00Z", + type: "run.started", + payload: {}, + }, + { + runId: "run_1", + sequence: 2, + timestamp: "2026-08-14T00:00:01Z", + type: "run.completed", + payload: {}, + }, + ]; + await registry.hydrate(events); + await registry.hydrate(events); + expect(registry.events("run_1")).toHaveLength(2); + expect(registry.isFinished("run_1")).toBe(true); + }); +}); diff --git a/apps/web-server/src/composition/run-registry.ts b/apps/web-server/src/composition/run-registry.ts index 1daa1e2..e4bfe15 100644 --- a/apps/web-server/src/composition/run-registry.ts +++ b/apps/web-server/src/composition/run-registry.ts @@ -10,6 +10,7 @@ export class RunRegistry { async publish(event: RunEvent): Promise { const events = this.history.get(event.runId) ?? []; + if (events.some((current) => current.sequence === event.sequence)) return; events.push(event); this.history.set(event.runId, events); if (event.type === "run.completed" || event.type === "run.failed") @@ -28,6 +29,13 @@ export class RunRegistry { ); } + async hydrate(events: readonly RunEvent[]): Promise { + for (const event of [...events].sort( + (left, right) => left.sequence - right.sequence, + )) + await this.publish(event); + } + events(runId: string): readonly RunEvent[] { return this.history.get(runId) ?? []; } diff --git a/apps/web-server/src/index.ts b/apps/web-server/src/index.ts index 65e9812..7afcd1b 100644 --- a/apps/web-server/src/index.ts +++ b/apps/web-server/src/index.ts @@ -101,11 +101,13 @@ const runs = new AgentRunService( interactions, workspace, ); +const runRegistry = new RunRegistry(); +await runRegistry.hydrate(await runs.recoverInterrupted()); const app = createApp( logger, conversations, runs, - new RunRegistry(), + runRegistry, projects, interactions, workspace, diff --git a/apps/web-server/src/routes/runs.ts b/apps/web-server/src/routes/runs.ts index 51bafe0..48be0bb 100644 --- a/apps/web-server/src/routes/runs.ts +++ b/apps/web-server/src/routes/runs.ts @@ -55,6 +55,7 @@ export function createRunRoutes( routes.get("/runs/:runId/events", (context) => streamSSE(context, async (stream) => { const runId = context.req.param("runId"); + await registry.hydrate(await service.listEvents(runId)); for (const event of registry.events(runId)) await writeEvent(stream, event); if (registry.isFinished(runId) || registry.isPaused(runId)) { diff --git a/apps/web/src/app/app-controller.ts b/apps/web/src/app/app-controller.ts index 30f91b5..a5c4b05 100644 --- a/apps/web/src/app/app-controller.ts +++ b/apps/web/src/app/app-controller.ts @@ -63,11 +63,15 @@ export function createAppController(): AppController { error.val = ""; toolActivities.val = []; try { - [recent.val, projects.val, settings.val] = await Promise.all([ + [recent.val, projects.val] = await Promise.all([ listConversations(), listProjects(), - getSettings(), ]); + try { + settings.val = await getSettings(); + } catch { + settings.val = null; + } } catch (cause) { error.val = readMessage(cause); } finally { diff --git a/docs/FEATURES.md b/docs/FEATURES.md index b9233f0..430c612 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,6 +1,6 @@ # 纵向功能 -状态:开发中 +状态:全部切片已完成,等待最终统一人工审核 需求版本:0.3.0(已确认、已冻结) 实施方案版本:0.4.0(已确认) 项目骨架:已确认(2026-08-12) @@ -120,9 +120,18 @@ ### F-010——恢复、边界与发布前加固 -- 状态:进行中 +- 状态:已完成(Agent 于 2026-08-14 验证通过,等待最终统一人工审核) - 用户可见结果:刷新、重启、目录离线和数据损坏等边界均有明确恢复行为。 - 主要验收:AC-006、AC-017 至 AC-019、AC-023、AC-029、AC-030、AC-036、AC-037。 +- 运行恢复:服务启动时检查遗留活动任务;执行中的 Run 转为带 `RUN_INTERRUPTED` 的可重试失败终态并补写持久化事件,等待用户回答且交互请求仍完整的 Run 保持等待状态。SSE 注册表会从本地事件日志恢复并按序去重,因此重连后可以重放已有事件。 +- 数据容错:会话、项目、运行和交互列表会隔离单个损坏的 JSON 记录;Run 事件日志允许忽略因意外断电产生的不完整末行,但中间损坏仍明确报错,避免悄悄跳过有效日志。设置加载失败不会阻断会话和项目历史进入页面。 +- 发布边界:所有 API 请求体限制为 5 MiB,超限返回稳定的 `REQUEST_TOO_LARGE`;新增敏感信息扫描,检查源代码和文档中的常见密钥、私钥及前端模型密钥引用。应用继续不包含身份认证端点,认证由部署时的反向代理负责。 +- 自动化验证结果:2026-08-14 通过全仓格式、代码、类型、文件规模、架构依赖和敏感信息检查,39 项测试和 134 个断言全部通过,Web 与 Web Server 生产构建成功。新增覆盖服务中断恢复、等待交互保留、事件重放去重、损坏记录隔离、不完整 NDJSON 末行及 5 MiB 请求边界。 +- 人工操作结果:2026-08-14 使用 F-009 的隔离数据目录重启实际生产构建;首次页面恢复“Markdown 验收”最近会话,选择后完整恢复列表、引用、表格、代码块、长消息和失败重试状态,证明发布构建可从真实本地文件重新构建页面状态。 + +## 最终统一人工审核 + +F-001 至 F-010 均已完成开发和 Agent 验证,当前进入功能验收阶段。建议按切片编号依次审核;只有用户明确回复“功能验收通过”后,阶段门才会标记为已确认。 ## 后续版本想法 diff --git a/package.json b/package.json index 226d197..5807f07 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "typecheck": "bun run --filter '*' typecheck", "check:file-size": "bun run scripts/check-file-size.ts", "check:architecture": "bun run scripts/check-architecture.ts", + "check:secrets": "bun run scripts/check-secrets.ts", "test": "bun test", "build": "bun run --filter @great-agent/web build && bun run --filter @great-agent/web-server build", "start": "bun apps/web-server/dist/index.js" diff --git a/packages/agent-core/src/use-cases/agent-run-service.test.ts b/packages/agent-core/src/use-cases/agent-run-service.test.ts index f6ad0be..d07c3da 100644 --- a/packages/agent-core/src/use-cases/agent-run-service.test.ts +++ b/packages/agent-core/src/use-cases/agent-run-service.test.ts @@ -322,6 +322,22 @@ describe("AgentRunService", () => { expect(events.map((event) => event.type)).toContain("tool.completed"); expect(events.at(-1)?.type).toBe("run.completed"); }); + + test("启动恢复把遗留 running 任务转为可重试失败", async () => { + const fixture = createFixture({ async *stream() {} }); + await fixture.runs.create({ + id: "interrupted_run", + conversationId: "conversation", + triggerMessageId: "message", + status: "running", + createdAt: "2026-08-14T00:00:00Z", + updatedAt: "2026-08-14T00:00:00Z", + }); + const events = await fixture.service.recoverInterrupted(); + expect(events[0]?.type).toBe("run.failed"); + expect(events[0]?.payload.code).toBe("RUN_INTERRUPTED"); + expect(fixture.runs.value?.status).toBe("failed"); + }); }); function createFixture(model: ModelPort) { diff --git a/packages/agent-core/src/use-cases/agent-run-service.ts b/packages/agent-core/src/use-cases/agent-run-service.ts index 412027b..f1e0c77 100644 --- a/packages/agent-core/src/use-cases/agent-run-service.ts +++ b/packages/agent-core/src/use-cases/agent-run-service.ts @@ -15,7 +15,9 @@ import { lastSequence, messagesThroughTrigger, persistRunEvent, + prepareRetryRun, requireRun, + recoverInterruptedRun, safeErrorMessage, } from "./run-service-helpers"; import type { StartedRun, StartRunInput } from "./run-types"; @@ -140,32 +142,13 @@ export class AgentRunService { async retry(runId: string, signal: AbortSignal): Promise { return this.withStartLock(async () => { - await ensureNoActiveRun(this.runs); - const original = await requireRun(this.runs, runId); - if (original.status !== "failed" && original.status !== "cancelled") - throw new CoreError( - "RUN_NOT_RETRYABLE", - "只有失败或已取消的任务可以重试", - ); - const conversation = await this.conversations.getConversation( - original.conversationId, + const run = await prepareRetryRun( + runId, + this.conversations, + this.runs, + this.clock, + this.ids, ); - const trigger = conversation.messages.find( - (message) => message.id === original.triggerMessageId, - ); - if (trigger?.role !== "user") - throw new CoreError("RUN_TRIGGER_INVALID", "原任务的用户消息不存在"); - const timestamp = this.clock.now().toISOString(); - const run: AgentRun = { - id: this.ids.create(), - conversationId: original.conversationId, - triggerMessageId: original.triggerMessageId, - retryOfRunId: original.id, - status: "running", - createdAt: timestamp, - updatedAt: timestamp, - }; - await this.runs.create(run); return this.started(run, signal, 0); }); } @@ -179,6 +162,21 @@ export class AgentRunService { return requireRun(this.runs, runId); } + async listEvents(runId: string): Promise { + await requireRun(this.runs, runId); + return this.runs.listEvents(runId); + } + + async recoverInterrupted(): Promise { + const active = await this.runs.findActive(); + if ( + active?.status === "waiting_user" && + (await this.interactions.findPendingByRun(active.id)) + ) + return []; + return recoverInterruptedRun(this.runs, this.clock); + } + private started( run: AgentRun, externalSignal: AbortSignal, diff --git a/packages/agent-core/src/use-cases/interaction-run.test.ts b/packages/agent-core/src/use-cases/interaction-run.test.ts index c887d88..286ba14 100644 --- a/packages/agent-core/src/use-cases/interaction-run.test.ts +++ b/packages/agent-core/src/use-cases/interaction-run.test.ts @@ -117,6 +117,10 @@ describe("等待用户的 Run", () => { const interaction = [...fixture.interactions.values.values()][0]; expect(interaction?.status).toBe("pending"); if (!interaction) throw new Error("interaction missing"); + expect(await fixture.service.recoverInterrupted()).toEqual([]); + expect(fixture.runs.values.get(started.run.id)?.status).toBe( + "waiting_user", + ); const resolved = await fixture.interactionService.resolve(interaction.id, { kind: "choice", selectedOptionIds: ["simple"], diff --git a/packages/agent-core/src/use-cases/interaction-service.ts b/packages/agent-core/src/use-cases/interaction-service.ts index ad11b26..3cbfca1 100644 --- a/packages/agent-core/src/use-cases/interaction-service.ts +++ b/packages/agent-core/src/use-cases/interaction-service.ts @@ -21,6 +21,10 @@ export class InteractionService { return this.dependencies.interactions.listByConversation(conversationId); } + findPendingByRun(runId: string) { + return this.dependencies.interactions.findPendingByRun(runId); + } + async get(id: string): Promise { const interaction = await this.dependencies.interactions.getById(id); if (!interaction) diff --git a/packages/agent-core/src/use-cases/run-service-helpers.ts b/packages/agent-core/src/use-cases/run-service-helpers.ts index e4cb77e..22d7665 100644 --- a/packages/agent-core/src/use-cases/run-service-helpers.ts +++ b/packages/agent-core/src/use-cases/run-service-helpers.ts @@ -1,8 +1,9 @@ -import type { RunEvent } from "../domain/agent-run"; +import type { AgentRun, RunEvent } from "../domain/agent-run"; import type { Conversation } from "../domain/conversation"; import { CoreError } from "../errors/core-error"; import type { RunRepository } from "../ports/run-repository"; -import type { ClockPort } from "../ports/system-ports"; +import type { ClockPort, IdPort } from "../ports/system-ports"; +import type { ConversationService } from "./conversation-service"; export function lastSequence(events: readonly RunEvent[]): number { return events.at(-1)?.sequence ?? 0; @@ -38,6 +39,60 @@ export async function requireRun(runs: RunRepository, id: string) { return run; } +export async function prepareRetryRun( + runId: string, + conversations: ConversationService, + runs: RunRepository, + clock: ClockPort, + ids: IdPort, +): Promise { + await ensureNoActiveRun(runs); + const original = await requireRun(runs, runId); + if (original.status !== "failed" && original.status !== "cancelled") + throw new CoreError("RUN_NOT_RETRYABLE", "只有失败或已取消的任务可以重试"); + const conversation = await conversations.getConversation( + original.conversationId, + ); + const trigger = conversation.messages.find( + (message) => message.id === original.triggerMessageId, + ); + if (trigger?.role !== "user") + throw new CoreError("RUN_TRIGGER_INVALID", "原任务的用户消息不存在"); + const timestamp = clock.now().toISOString(); + const run: AgentRun = { + id: ids.create(), + conversationId: original.conversationId, + triggerMessageId: original.triggerMessageId, + retryOfRunId: original.id, + status: "running", + createdAt: timestamp, + updatedAt: timestamp, + }; + await runs.create(run); + return run; +} + +export async function recoverInterruptedRun( + runs: RunRepository, + clock: ClockPort, +): Promise { + const run = await runs.findActive(); + if (!run) return []; + const sequence = lastSequence(await runs.listEvents(run.id)) + 1; + await runs.update({ + ...run, + status: "failed", + errorCode: "RUN_INTERRUPTED", + updatedAt: clock.now().toISOString(), + }); + return [ + await persistRunEvent(runs, clock, run.id, sequence, "run.failed", { + code: "RUN_INTERRUPTED", + message: "服务重启中断了本次任务,请重试", + }), + ]; +} + export async function persistRunEvent( runs: RunRepository, clock: ClockPort, diff --git a/packages/local-data/src/repositories/file-conversation-repository.test.ts b/packages/local-data/src/repositories/file-conversation-repository.test.ts index f870b13..a597e9d 100644 --- a/packages/local-data/src/repositories/file-conversation-repository.test.ts +++ b/packages/local-data/src/repositories/file-conversation-repository.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import type { Conversation } from "@great-agent/agent-core"; @@ -41,5 +41,7 @@ describe("FileConversationRepository", () => { expect((await repository.getById(conversation.id))?.messages).toHaveLength( 1, ); + await writeFile(join(layout.conversations, "damaged.json"), "{broken"); + expect(await repository.listRecent()).toHaveLength(1); }); }); diff --git a/packages/local-data/src/repositories/file-conversation-repository.ts b/packages/local-data/src/repositories/file-conversation-repository.ts index 41e4050..39532fb 100644 --- a/packages/local-data/src/repositories/file-conversation-repository.ts +++ b/packages/local-data/src/repositories/file-conversation-repository.ts @@ -91,17 +91,25 @@ export class FileConversationRepository implements ConversationRepository { private async readAll(): Promise { await mkdir(this.layout.conversations, { recursive: true }); const names = await readdir(this.layout.conversations); - return Promise.all( + const values = await Promise.all( names .filter((name) => name.endsWith(".json")) .map(async (name) => { - return normalizeConversation( - JSON.parse( - await readFile(join(this.layout.conversations, name), "utf8"), - ) as Conversation, - ); + try { + return normalizeConversation( + JSON.parse( + await readFile(join(this.layout.conversations, name), "utf8"), + ) as Conversation, + ); + } catch (cause) { + if (cause instanceof SyntaxError) return null; + throw cause; + } }), ); + return values.filter( + (conversation): conversation is Conversation => conversation !== null, + ); } } diff --git a/packages/local-data/src/repositories/file-interaction-repository.ts b/packages/local-data/src/repositories/file-interaction-repository.ts index 88326d1..daff39a 100644 --- a/packages/local-data/src/repositories/file-interaction-repository.ts +++ b/packages/local-data/src/repositories/file-interaction-repository.ts @@ -61,15 +61,22 @@ export class FileInteractionRepository implements InteractionRepository { const directory = this.directory(runId); try { const names = await readdir(directory); - return Promise.all( + const values = await Promise.all( names .filter((name) => name.endsWith(".json")) - .map( - async (name) => - JSON.parse( + .map(async (name) => { + try { + return JSON.parse( await readFile(join(directory, name), "utf8"), - ) as UserInteraction, - ), + ) as UserInteraction; + } catch (cause) { + if (cause instanceof SyntaxError) return null; + throw cause; + } + }), + ); + return values.filter( + (interaction): interaction is UserInteraction => interaction !== null, ); } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") diff --git a/packages/local-data/src/repositories/file-project-repository.ts b/packages/local-data/src/repositories/file-project-repository.ts index dba4c1d..e16cd4e 100644 --- a/packages/local-data/src/repositories/file-project-repository.ts +++ b/packages/local-data/src/repositories/file-project-repository.ts @@ -13,16 +13,20 @@ export class FileProjectRepository implements ProjectRepository { const projects = await Promise.all( names .filter((name) => name.endsWith(".json")) - .map( - async (name) => - JSON.parse( + .map(async (name) => { + try { + return JSON.parse( await readFile(join(this.layout.projects, name), "utf8"), - ) as Project, - ), - ); - return projects.sort((left, right) => - right.updatedAt.localeCompare(left.updatedAt), + ) as Project; + } catch (cause) { + if (cause instanceof SyntaxError) return null; + throw cause; + } + }), ); + return projects + .filter((project): project is Project => project !== null) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); } async getById(id: string): Promise { diff --git a/packages/local-data/src/repositories/file-run-repository.test.ts b/packages/local-data/src/repositories/file-run-repository.test.ts index 3382974..863454f 100644 --- a/packages/local-data/src/repositories/file-run-repository.test.ts +++ b/packages/local-data/src/repositories/file-run-repository.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { appendFile, mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import type { AgentRun, RunEvent } from "@great-agent/agent-core"; @@ -41,5 +41,10 @@ describe("FileRunRepository", () => { "message_1", ); expect(await repository.listEvents(run.id)).toEqual([event]); + await appendFile( + join(layout.runs, run.id, "events.ndjson"), + '{"sequence":2', + ); + expect(await repository.listEvents(run.id)).toEqual([event]); }); }); diff --git a/packages/local-data/src/repositories/file-run-repository.ts b/packages/local-data/src/repositories/file-run-repository.ts index efea4df..c0cbdf5 100644 --- a/packages/local-data/src/repositories/file-run-repository.ts +++ b/packages/local-data/src/repositories/file-run-repository.ts @@ -48,11 +48,16 @@ export class FileRunRepository implements RunRepository { async listEvents(runId: string): Promise { try { const text = await readFile(this.eventsPath(runId), "utf8"); - return text - .trim() - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line) as RunEvent); + const lines = text.split("\n").filter(Boolean); + const events: RunEvent[] = []; + for (const [index, line] of lines.entries()) { + try { + events.push(JSON.parse(line) as RunEvent); + } catch (cause) { + if (index !== lines.length - 1) throw cause; + } + } + return events; } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") return []; @@ -93,7 +98,14 @@ export class FileRunRepository implements RunRepository { const values = await Promise.all( names .filter((entry) => entry.isDirectory()) - .map((entry) => this.getById(entry.name)), + .map(async (entry) => { + try { + return await this.getById(entry.name); + } catch (cause) { + if (cause instanceof SyntaxError) return null; + throw cause; + } + }), ); return values.filter((run): run is AgentRun => run !== null); } diff --git a/scripts/check-secrets.ts b/scripts/check-secrets.ts new file mode 100644 index 0000000..33466d4 --- /dev/null +++ b/scripts/check-secrets.ts @@ -0,0 +1,23 @@ +const excluded = /(^|\/)(node_modules|dist|data|workspace|\.git)(\/|$)/u; +const suspicious = [ + /sk-[A-Za-z0-9_-]{20,}/u, + /-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/u, +]; +const violations: string[] = []; + +for await (const path of new Bun.Glob( + "**/*.{ts,js,json,md,yaml,yml,css,html}", +).scan(".")) { + if (excluded.test(path)) continue; + const content = await Bun.file(path).text(); + if (suspicious.some((pattern) => pattern.test(content))) + violations.push(`${path}: 疑似包含真实凭据`); + if (path.startsWith("apps/web/") && content.includes("DEEPSEEK_API_KEY")) + violations.push(`${path}: 客户端不得引用模型密钥环境变量`); +} + +if (violations.length) { + console.error(violations.join("\n")); + process.exit(1); +} +console.log("敏感信息检查通过。");