Skip to content

merge: dev -> master(Daily Push + MinerU v4 + README sync)#230

Merged
jerry609 merged 12 commits into
masterfrom
dev
Mar 4, 2026
Merged

merge: dev -> master(Daily Push + MinerU v4 + README sync)#230
jerry609 merged 12 commits into
masterfrom
dev

Conversation

@jerry609

@jerry609 jerry609 commented Mar 4, 2026

Copy link
Copy Markdown
Owner

说明

dev 分支最新 9 个提交合并到 master

主要变更

  • Daily Push 链路完善:MinerU v4 官方任务流标准化(/extract/task + 轮询)。
  • 邮件模板增强:主方法图支持公网 URL 渲染与 data:image/... inline 回退。
  • DailyPaper:主图提取与报告渲染逻辑补齐。
  • README 更新:Phase 6(每日推送优化)描述同步、Email 推送截图替换。
  • 其他修复:web 构建脚本 JSON 语法修复、.env 相关忽略规则完善。

关联提交

备注

仓库默认分支当前为 master(非 main),本 PR 以 master 为目标分支。

Summary by CodeRabbit

  • New Features

    • MinerU-backed figure extraction with inline-image fallback; main figures displayed in DailyPaper emails
    • Context pack generation (title/abstract optional) integrated into studio flows
    • Paper Gallery, PaperIcon, and updated studio workspace UI; per-paper state caching and improved New Paper import
  • Improvements

    • More resilient generation/persistence and richer streaming events; safer runtime path validation and duplicate-title checks
    • CLI/config options for MinerU (base URL, model, timeouts); .gitignore and README updates
  • Tests

    • Added unit and e2e tests for MinerU, figure handling, studio store, and UI smoke flows
  • Documentation

    • Expanded README and design proposal; env.example updated

jerry609 and others added 9 commits March 3, 2026 18:03
* feat(push): implement Epic #179 — daily push optimization

Multi-channel push infrastructure with structured digest cards:

- #187: Integrate HF upvotes into Judge scoring prompt
- #180: Add digest_card extraction (highlight/method/finding/tags)
  with new LLM prompt, email template rendering, markdown output
- #181: MinerU Cloud API client for PDF figure extraction
- #182: Apprise multi-channel push layer with YAML config
- #183-185: Channel formatters (Telegram MarkdownV2, Discord Rich
  Embed, WeCom markdown+news, Feishu/Lark interactive card)
- #186: RSS 2.0 + Atom feed endpoints (/api/feed/daily.xml, .atom)

Also creates follow-up issues #190 (Push Channel Settings UI)
and #191 (Dashboard Digest Card Enhancement).

56 new tests, all passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: integrate MinerU figure extraction into CLI and ARQ worker daily-paper workflow

Refs #179

* fix(daily-push): enable MinerU flags in ARQ cron payload (refs #181)

* feat(push): wire channel formatters into apprise digest delivery (refs #182 #183 #184 #185)

* feat(feed): add keyword RSS, feed cache, and figure enclosure support (refs #186)

* feat(mineru): add extraction result cache for daily push figure pipeline (refs #181)

* feat(push): add telegram bot command subscription endpoint (refs #191)

* fix(push): add retry/backoff for channel delivery failures (refs #191)

* feat(hf): add trending modes, cache, and arxiv identity dedupe (refs #193)

* feat(push): add idempotency and error-code mapping for channel delivery (refs #191)

* test(push): add mock channel e2e and failure-injection coverage (refs #191)

* docs(push): add audit script and ops runbook for acceptance closure (refs #192)

* docs: update README for AgentSwarm execution transition

* docs: archive stale docs and rename AgentSwarm todo

* docs: add AgentSwarm design proposal + web Playwright e2e setup

- Add docs/proposals/agentswarm-design.md covering multi-agent
  orchestration architecture, adapter pattern, session management,
  skills ecosystem, and OpenCode/OpenClaw/oMo integration (refs #197, #214)
- Add Playwright e2e test scaffolding for web dashboard (smoke,
  papers, research specs)
- Update web/.gitignore for Playwright artifacts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…LI chat errors (#223)

* fix(studio): fix context pack generation, duplicate import UX, and CLI chat errors

- 支持内联标题/摘要,以绕过 PaperInputRouter 获取 studio 论文 ID

- 使上下文包生成 SSE 流期间的数据库持久化操作不再致命

- 从库中导入重复论文时显示错误消息

- 将左侧文件面板宽度缩小至约 18%(默认宽度);移除默认模板文件

- 为 Claude CLI 添加 --verbose 标志,以兼容 stream-json 输出

Related to #222

* fix(studio): fix context pack generation, duplicate import UX, and CLI chat errors
- 修改Gemini AI review代码问题

* fix(studio): fix context pack generation, duplicate import UX, and CLI chat errors
- 修改Gemini AI review代码问题

---------

Co-authored-by: Jerry Zhang <1772030600@qq.com>
* fix(mineru): support v4 async task extraction flow

* refactor(mineru): standardize on v4 task API for daily push (#179)
Copilot AI review requested due to automatic review settings March 4, 2026 08:46
@vercel

vercel Bot commented Mar 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
paper-bot Ready Ready Preview, Comment Mar 4, 2026 9:34am

@coderabbitai

coderabbitai Bot commented Mar 4, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@jerry609 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 13 minutes and 20 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 57715d81-0aa1-4636-85cc-1094406b15c8

📥 Commits

Reviewing files that changed from the base of the PR and between 9fb8639 and b5a6cef.

📒 Files selected for processing (1)
  • src/paperbot/api/routes/studio_chat.py
📝 Walkthrough

Walkthrough

Integrates MinerU v4 async task-based figure extraction with inline-data fallback; adds title/abstract inline paper support for context-pack generation and structured CLI streaming; implements Studio PaperGallery, PaperIcon, per-paper state caching and streaming improvements; expands CLI/ARQ wiring and Playwright/Vitest test infra; adds AgentSwarm design doc and README updates.

Changes

Cohort / File(s) Summary
MinerU extractor & pipeline
src/paperbot/infrastructure/extractors/mineru_client.py, src/paperbot/application/workflows/dailypaper.py, env.example
Adds MinerU v4 task workflow (POST/GET task), polling, ZIP/Markdown parsing, inline_data_url support, URL/host validation, Figure.inline_data_url, and config params (base_url, model_version, max_wait_seconds).
Job & CLI wiring
src/paperbot/infrastructure/queue/arq_worker.py, src/paperbot/presentation/cli/main.py, tests/unit/test_arq_daily_papers.py
Propagates MinerU config through ARQ daily_papers job and CLI flags/env handling; validates and defaults max_wait_seconds.
Context-pack API & streaming
src/paperbot/api/routes/repro_context.py, src/paperbot/api/routes/studio_chat.py, web/src/hooks/useContextPackGeneration.ts
Adds optional title/abstract inline fields and RawPaperData bypass; robust persistence guarding; structured CLI/NDJSON streaming and SSE error parsing; hook accepts title/abstract.
Email / Digest figure rendering & tests
src/paperbot/application/services/email_template.py, tests/unit/test_daily_digest_card.py
Adds _main_figure_html to render http or data:image inline main figures (excludes ZIP refs) and injects into paper card HTML; tests for inline/http/zip cases.
Studio UI: gallery, modal, icon, page
web/src/components/studio/PaperGallery.tsx, web/src/components/studio/NewPaperModal.tsx, web/src/components/studio/PaperIcon.tsx, web/src/app/studio/page.tsx
Adds PaperGallery, PaperIcon identicon, title-based duplicate detection in NewPaperModal, and refactors studio page to 2-panel workspace with gallery/workspace switching.
Studio store per-paper caching & tests
web/src/lib/store/studio-store.ts, web/src/lib/store/project-context.ts, web/src/lib/store/studio-store.test.ts
Introduces PerPaperCache to persist context-pack state across paper switches, adds appendToLastAction, removes default sample files, and adds tests for caching/append behavior.
ReproductionLog streaming & hook surface
web/src/components/studio/ReproductionLog.tsx, web/src/hooks/useContextPackGeneration.ts
Consumes structured cli_event stream types (text/tool_use/tool_result/thinking/keepalive), groups text streaming, exposes generate/status; hook updated to include title/abstract and robust SSE error handling.
MinerU tests & dailypaper changes/tests
tests/unit/test_mineru_client.py, tests/unit/test_dailypaper.py
Adds unit tests for MinerU URL validation, unsupported-host rejection, markdown/zip parsing, inline data extraction, and _is_publishable_figure_url; tests for inline main_figure handling.
E2E & test infra (web)
web/playwright.config.ts, web/vitest.config.ts, web/e2e/*.spec.ts, web/package.json, web/.gitignore
Adds Playwright & Vitest configs, e2e tests (smoke/papers/research), npm test scripts, and ignores for test artifacts.
Runbook allowlist & file ops
src/paperbot/api/routes/runbook.py, web/src/components/studio/PapersPanel.tsx, web/src/lib/runbook/deleteProjectFiles.ts
Tightens directory normalization using realpath, explicit denies (home/roots), validates directory exist/type; replaces API delete call with deleteProjectFiles utility that lists & deletes project files.
Docs, README, design proposal
README.md, docs/proposals/agentswarm-design.md
Updates README DailyPaper/Push sections (MinerU, inline fallback, Digest/Judge cards, Apprise channels) and adds AgentSwarm design proposal doc.
Misc config & ignores
.gitignore, web/.gitignore, web/next.config.ts
Adds recursive .env ignore patterns and test artifact ignores; enables httpAgentOptions.keepAlive in Next config.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client (Caller)
    participant MineruClient as MineruClient
    participant MineruAPI as MinerU API
    participant ZipHandler as ZIP/Markdown Handler

    Client->>MineruClient: extract_figures(pdf_url, model_version, base_url)
    MineruClient->>MineruClient: _validate_source_url()
    MineruClient->>MineruAPI: POST /extract/task (pdf_url, model_version)
    MineruAPI-->>MineruClient: { task_id }

    loop Poll until done
        MineruClient->>MineruAPI: GET /extract/task/{task_id}
        MineruAPI-->>MineruClient: { status, payload, zip_url }
    end

    alt payload contains inline figures
        MineruClient->>MineruClient: parse inline payload → List[Figure]
    else zip available
        MineruClient->>ZipHandler: _extract_figures_from_zip(zip_url)
        ZipHandler->>ZipHandler: _parse_figures_from_markdown()
        ZipHandler->>ZipHandler: _build_inline_data_url()
        ZipHandler-->>MineruClient: List[Figure]
    end

    MineruClient-->>Client: List[Figure] (url + inline_data_url)
Loading
sequenceDiagram
    participant User as Frontend User
    participant API as Repro Context API
    participant Orchestrator as Orchestrator/Service
    participant LLM as LLM/Claude
    participant Store as Persistence Store

    User->>API: POST /repro-context/generate (title?, abstract?, depth)
    API->>Orchestrator: run(raw_paper=RawPaperData?) 
    Orchestrator->>LLM: request generation
    LLM-->>Orchestrator: stream cli_event JSON lines
    Orchestrator->>API: SSE events (progress/observations/errors)
    Orchestrator->>Store: save context pack / save stage results (guarded)
    API-->>User: stream events and final pack id
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related issues

  • #214: Adds and finalizes the AgentSwarm design doc; matches this PR's addition of docs/proposals/agentswarm-design.md.

Possibly related PRs

  • #76: Related — both modify ARQ daily_papers_job and MinerU/figure extraction wiring.
  • #136: Related — both change Studio Chat streaming and frontend studio integrations.
  • #148: Related — both touch Paper→Context (p2c) flows, orchestrator/hooks, and API/frontend interactions.

Poem

🐰 I hopped through code and mined each figure bright,

MinerU hums and renders inline light,
Context packs grow from titles and a phrase,
Gallery blooms in two-panel studio ways,
Tests stand guard as swarm ideas take flight.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.07% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main changes: daily push pipeline improvements with MinerU v4, email template enhancements, and README synchronization across multiple files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dev

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

此拉取请求主要通过集成 MinerU v4 提升了“每日推送”功能,改进了图片提取和邮件模板渲染。同时,它优化了 Studio 的用户体验,包括新的论文画廊、更清晰的状态指示和更强大的上下文包处理。一个重要的里程碑是引入了 AgentSwarm 设计提案,为未来构建论文到代码复现的多 Agent 架构奠定了基础。此外,还包括了 .gitignore 更新和后端服务错误处理等次要修复,全面提升了系统的功能性、用户体验和可维护性。

Highlights

  • 每日推送功能增强: 集成了 MinerU v4 官方任务流,支持主方法图提取(公网 URL 和 inline data URL 回退),并完善了 LLM Digest 和 Judge 评分卡片,以及 Apprise 多渠道推送。
  • Studio UI/UX 改进: 引入了新的论文画廊视图,优化了论文状态显示,并改进了论文删除功能。Studio 聊天功能现在利用 Claude CLI 的 stream-json 输出,提供结构化、实时的交互体验,并支持加载上下文包。
  • AgentSwarm 设计提案: 新增了 AgentSwarm Design Proposal 文档,详细阐述了将论文转换为可复现代码的多 Agent 协作平台架构,包括 UI/UX、API 设计、ACI 命令、路线图以及与 OpenCode/OpenClaw 生态的集成策略。
  • 配置和环境管理优化: 更新了 .gitignore 以更全面地忽略 .env 文件和 Playwright 测试产物。env.example 新增了 MinerU API 配置项。runbook.py 调整了运行时允许列表的默认设置,并改进了目录路径规范化和安全检查。
  • 后端服务健壮性提升: repro_context.py 增加了对内联论文数据的支持,并为数据库操作添加了错误日志记录,以提高稳定性。mineru_client.py 重构为使用 MinerU v4 异步任务 API,并增强了对 ZIP 档案中图片和内联数据的处理能力。
  • 前端测试框架引入: 为前端项目引入了 Playwright E2E 测试框架,并添加了针对主要页面(如 Papers、Research、Studio)的端到端测试和冒烟测试,以确保页面正常渲染和基本功能可用。

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • .gitignore
    • .env 文件和 Playwright 测试产物添加了新的忽略规则。
  • README.md
    • 更新了“每日推送”部分,以反映 MinerU v4 集成、LLM Digest、Judge 评分卡片和 Apprise 多渠道支持。
    • 将“推送/通知”状态更新为“✅ 可用”,并替换了邮件通知截图。
    • 在 Phase 6 中添加了关于 MinerU v4、邮件渲染、内容增强、Apprise 和 HuggingFace API 集成的详细信息。
  • docs/proposals/agentswarm-design.md
    • 新增了 AgentSwarm 设计提案文档,详细介绍了用于论文到可复现代码的多 Agent 协作平台,包括架构、UI/UX、API 设计、ACI 命令、路线图以及与 OpenCode/OpenClaw 生态系统的集成。
  • env.example
    • 添加了 MinerU API 配置的新环境变量(key、base URL、模型版本、最大等待秒数)和 DailyPaper 图片提取设置。
  • src/paperbot/api/routes/repro_context.py
    • 导入了 RawPaperData,为 GenerateContextPackRequest 添加了 titleabstract 以支持内联论文数据。
    • 为数据库保存/更新操作添加了 try-except 块,以记录警告而不是直接失败。
    • 修改了 orchestrator.run 以接受 raw_paper 用于内联数据。
  • src/paperbot/api/routes/runbook.py
    • PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION 的默认值更改为 true
    • 重构了 add_allowed_dir,以改进目录路径规范化、处理 ~ 扩展,并添加了对禁止路径(包括主目录)更强大的检查。
  • src/paperbot/api/routes/studio_chat.py
    • 导入了 loggingPath
    • StudioChatRequest 添加了 context_pack_id
    • 实现了将上下文包加载并格式化为 Markdown 的函数(_load_context_pack_format_context_pack_markdown_ensure_context_pack_on_disk)。
    • 重构了 stream_claude_cli,以使用 Claude CLI 的 stream-json 输出格式,解析结构化事件(文本、工具使用、工具结果、思考),并将上下文包包含在工作目录中。
    • 添加了 _parse_cli_content_blocks_parse_cli_event_truncate 辅助函数。
  • src/paperbot/application/services/email_template.py
    • 添加了 _main_figure_html 函数,用于在邮件模板中渲染主方法图,支持公网 URL 和内联数据 URL。
    • _main_figure_html 集成到 _paper_card_full_html 中。
  • src/paperbot/application/workflows/dailypaper.py
    • 添加了 _is_publishable_figure_url 以过滤非公网 URL。
    • 修改了 extract_figures_for_report,以接受 base_urlmodel_versionmax_wait_seconds 用于 MinerU 客户端配置。
    • 更新了逻辑,以便在可用和可发布时存储主图的 inline_data_url
  • src/paperbot/infrastructure/extractors/mineru_client.py
    • 更新了文档字符串,以反映 MinerU v4 异步任务 API。
    • inline_data_url 添加到 Figure 数据类中。
    • 重构了 MineruClient,以使用异步任务 API(_create_task_poll_until_done_request_json)。
    • 添加了 _validate_source_url 以检查不支持的主机(github/aws)。
    • 实现了 _extract_figures_from_zip_parse_figures_from_markdown_build_inline_data_url_resolve_figure_url,用于处理 zip 档案中的图片和内联数据。
    • 更新了 __init__ 以接受 model_versionpoll_interval_secondsmax_wait_seconds
  • src/paperbot/infrastructure/queue/arq_worker.py
    • 添加了 _parse_float_env 辅助函数。
    • 更新了 cron_daily_papersdaily_papers_job,以将 MinerU API 配置(base URL、模型版本、最大等待秒数)传递给每日论文生成过程。
  • src/paperbot/presentation/cli/main.py
    • 添加了 MinerU API 配置的新 CLI 参数(--mineru-api-base-url--mineru-model-version--mineru-max-wait-seconds)。
    • 更新了 _run_daily_paper,以使用这些新参数和环境变量进行 MinerU 客户端配置。
  • tests/unit/test_arq_daily_papers.py
    • 添加了 MinerU 环境变量的 monkeypatching,并断言了它们在排队作业中的值。
  • tests/unit/test_daily_digest_card.py
    • 添加了 test_main_figure_inline_in_email_html,以验证内联主图在邮件 HTML 中是否正确渲染。
  • tests/unit/test_dailypaper.py
    • 导入了 _is_publishable_figure_urlFigure
    • 添加了 test_publishable_figure_url_rejects_zip_artifactstest_extract_figures_for_report_keeps_inline_main_figure,以测试图片提取逻辑。
  • tests/unit/test_mineru_client.py
    • 导入了 iozipfile
    • 添加了 _validate_source_url 的测试(http 要求、github/aws 拒绝)和 test_extract_figures_rejects_unsupported_host_early
    • 添加了 _parse_figures_from_markdown_zip_refstest_parse_figures_from_markdown_with_inline_zip_image 的测试,以验证从 markdown 解析图片和处理 zip 文件中的内联图片。
  • web/.gitignore
    • 添加了 Playwright 测试结果和缓存目录的忽略规则。
  • web/e2e/papers.spec.ts
    • 为论文页面添加了新的 Playwright E2E 测试,检查保存的论文部分和导入/导出选项。
  • web/e2e/research.spec.ts
    • 为研究页面添加了新的 Playwright E2E 测试,检查搜索输入和跟踪/主题部分。
  • web/e2e/smoke.spec.ts
    • 为各个页面(主页、仪表板、论文、研究、学者、设置、工作室)添加了新的 Playwright E2E 冒烟测试,以确保它们在没有应用程序错误的情况下渲染。
  • web/next.config.ts
    • 在 Next.js 配置中添加了 httpAgentOptions.keepAlive: true
  • web/package-lock.json
    • 更新了 package-lock.json 以包含 @playwright/testplaywright 依赖项。
  • web/package.json
    • 添加了新的 Playwright E2E 测试脚本(test:e2etest:e2e:uitest:e2e:headedtest:e2e:report)和 vitest
    • @playwright/testvitest 添加到 devDependencies 中。
  • web/playwright.config.ts
    • 添加了新的 Playwright 配置,用于 E2E 测试,定义了测试目录、并行执行、重试、报告器、基本 URL 和 Web 服务器设置。
  • web/src/app/studio/page.tsx
    • 导入了 ArrowLeftLoader2Buttoncn
    • 添加了 statusConfig 用于论文状态显示。
    • 重构了 StudioContent,以便在未选择论文时显示 PaperGallery,否则显示工作区。
    • 更新了顶部栏,以包含返回按钮、论文标题和状态指示器。
    • 将桌面布局更改为 2 面板(文件面板左侧,复现日志右侧),移动布局更改为 2 标签页。
  • web/src/components/studio/NewPaperModal.tsx
    • 添加了防止添加重复标题论文(不区分大小写)的逻辑。
    • 改进了导入时重复论文的错误显示。
    • studioPaperIds 更改为 studioPaperTitles,以进行基于标题的唯一性检查。
  • web/src/components/studio/PaperGallery.tsx
    • 新增了用于在 Studio 中显示论文画廊的组件,包括搜索、添加新论文按钮、带有状态和相对时间的论文卡片以及带确认的删除功能。
  • web/src/components/studio/PaperIcon.tsx
    • 新增了根据论文 ID 和标题生成确定性几何图标的组件。
  • web/src/components/studio/ReproductionLog.tsx
    • 导入了 useContextPackGenerationPlay 图标。
    • appendToLastAction 添加到 useStudioStore 中。
    • 重构了 stream_claude_cli 输出处理,以处理 Claude CLI 的结构化 stream-json 事件,将文本附加到最后一个操作以形成连续的气泡,并为工具使用/结果创建新操作。
    • 为生成面板添加了空闲状态,并带有“生成上下文包”按钮。
    • 更新了上下文包面板的空状态消息。
  • web/src/hooks/useContextPackGeneration.ts
    • generate 参数添加了 titleabstract,以支持内联论文数据。
    • 修改了 /api/research/repro/context 请求的 payload,以在提供时包含 titleabstract
    • 改进了从 SSE 事件中提取错误消息的功能。
  • web/src/lib/store/project-context.ts
    • DEFAULT_FILES 中删除了默认示例文件(model.pytrain.pyconfig.yaml)。
  • web/src/lib/store/studio-store.test.ts
    • 新增了 studio-store 的测试文件,其中包含用于往返缓存的每论文状态和将文本附加到最后一个操作的测试。
  • web/src/lib/store/studio-store.ts
    • _paperCache 添加到 StudioState 中,以存储每论文缓存状态。
    • 修改了 deletePaper,以从 _paperCache 中删除论文。
    • 重构了 selectPaper,以便在切换之前将当前论文的状态保存到缓存中,并为新选择的论文恢复缓存状态(或默认值)。
    • 添加了 appendToLastAction,以将文本附加到任务的最后一个操作。
  • web/vitest.config.ts
    • 新增了 Vitest 配置文件。
Activity
  • 此拉取请求将 dev 分支合并到 master
  • 它包含了 9 个关联提交。
  • 作者 jerry609 提供了详细的变更说明。
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Comment thread src/paperbot/api/routes/runbook.py Fixed

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces significant, high-quality updates primarily focused on enhancing the Daily Push pipeline, refactoring the Studio UI/UX, and optimizing integration with the Claude CLI. Key improvements include MinerU v4 integration, a refactored Studio with a "Gallery-Workspace" mode, and upgraded Claude CLI interaction using --output-format stream-json for richer chat UI. Comprehensive testing has also been added, and efforts were made to enhance robustness and security in various modules. However, the pull request also introduces several critical security vulnerabilities, most notably a Path Traversal vulnerability within the Claude CLI integration. This vulnerability could allow arbitrary directory execution with elevated permissions, potentially leading to Remote Code Execution. Additionally, instances of Broken Access Control (IDOR) and insecure file handling logic were identified, which could result in unauthorized data access or modification. A potential security risk related to default configurations was also noted. To mitigate these critical issues, it is strongly recommended to implement strict directory allowlisting for all subprocess operations and enforce ownership checks on all resource-accessing endpoints.

Comment thread src/paperbot/api/routes/studio_chat.py Outdated
Comment on lines 372 to 375
cwd = request.project_dir or os.getcwd()
if not os.path.isdir(cwd):
cwd = os.getcwd()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

The stream_claude_cli function uses request.project_dir directly as the working directory (cwd) for a subprocess without any validation against an allowlist. Since the Claude CLI is executed with the --dangerously-skip-permissions flag (line 75), an attacker can specify any directory on the system (e.g., /, /etc, /home/user/.ssh) and have the AI agent perform arbitrary file operations or execute commands in that context. This leads to Remote Code Execution (RCE) and full system compromise.

Comment on lines +377 to +382
if request.context_pack_id:
pack_path = await asyncio.to_thread(
_ensure_context_pack_on_disk, request.context_pack_id, cwd,
)
if pack_path:
log.info("Wrote context pack to %s", pack_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The _ensure_context_pack_on_disk function writes a CONTEXT.md file to the project_dir provided in the request. Because project_dir is not validated, an attacker can use path traversal to write this file into arbitrary directories on the system. Combined with the ability to control context pack content, this allows for arbitrary file creation/overwrite in sensitive locations.

Comment thread src/paperbot/api/routes/runbook.py Outdated

def _runtime_allowlist_mutation_enabled() -> bool:
return os.getenv("PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION", "false").lower() == "true"
return os.getenv("PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION", "true").lower() == "true"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

此处的默认值从 "false" 更改为了 "true",这使得在默认情况下启用了用于在运行时修改目录白名单的 API 端点。

虽然该端点本身包含验证逻辑,但默认启用一个安全敏感的功能可能会增加攻击面。通常建议遵循“默认安全”的原则,即此类功能应为选择性加入(opt-in)。

建议考虑将默认值改回 "false",除非在目标部署环境中默认启用此功能是经过深思熟虑的设计决策。

Suggested change
return os.getenv("PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION", "true").lower() == "true"
return os.getenv("PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION", "false").lower() == "true"

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR merges recent dev work into the default branch, focusing on stabilizing the Daily Push pipeline (MinerU v4 async workflow + figure rendering), improving Studio UX/state handling, and adding web test scaffolding (Vitest + Playwright).

Changes:

  • Integrate MinerU v4 async task flow (create + poll), add URL validation, and support inline data:image/... fallback for main figures in DailyPaper/email rendering.
  • Improve Studio UX: paper gallery view, deterministic paper icons, per-paper cached state across switches, and structured Claude CLI streaming (tool/text/thinking events).
  • Add web testing setup: Vitest unit tests for the store, Playwright config + basic E2E smoke specs, plus ignore rules for test artifacts.

Reviewed changes

Copilot reviewed 32 out of 35 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
web/vitest.config.ts Adds Vitest configuration for web unit tests.
web/src/lib/store/studio-store.ts Adds per-paper cached state and streaming text append support for tasks.
web/src/lib/store/studio-store.test.ts Adds unit tests for per-paper cache switching and streaming append behavior.
web/src/lib/store/project-context.ts Removes default virtual files (starts with empty workspace).
web/src/hooks/useContextPackGeneration.ts Allows sending optional inline title/abstract to the context pack generator and hardens error parsing.
web/src/components/studio/ReproductionLog.tsx Consumes structured SSE events (text/tool/thinking), adds “Generate Context Pack” idle CTA, passes context_pack_id to chat backend.
web/src/components/studio/PaperIcon.tsx Adds deterministic SVG identicon + abbreviation helper for papers.
web/src/components/studio/PaperGallery.tsx Adds gallery-based paper selection/search and deletion flow.
web/src/components/studio/NewPaperModal.tsx Adds duplicate-title detection for manual add/import and improves error display.
web/src/app/studio/page.tsx Switches Studio to gallery-first UX and simplifies workspace panels (Files + Log).
web/playwright.config.ts Adds Playwright configuration for web E2E tests.
web/package.json Adds test scripts plus Vitest + Playwright dev dependencies.
web/package-lock.json Locks new Playwright/Vitest dependency graph.
web/next.config.ts Enables HTTP keep-alive agent options.
web/e2e/smoke.spec.ts Adds basic navigation smoke coverage.
web/e2e/research.spec.ts Adds basic Research page E2E checks.
web/e2e/papers.spec.ts Adds Papers page E2E checks.
web/.gitignore Ignores Playwright/Vitest artifact outputs.
tests/unit/test_mineru_client.py Adds MinerU URL validation + markdown/zip reference parsing coverage.
tests/unit/test_dailypaper.py Adds publishable-URL filtering tests and inline main-figure behavior test.
tests/unit/test_daily_digest_card.py Verifies inline main figure is rendered into email HTML.
tests/unit/test_arq_daily_papers.py Verifies MinerU env settings propagate through ARQ cron/job kwargs.
src/paperbot/presentation/cli/main.py Adds CLI flags/env wiring for MinerU base URL/model/wait time and passes them into figure extraction.
src/paperbot/infrastructure/queue/arq_worker.py Adds MinerU env parsing and passes MinerU settings through cron/job.
src/paperbot/infrastructure/extractors/mineru_client.py Reworks MinerU client to v4 async task API, adds polling, zip/markdown parsing, and inline image extraction.
src/paperbot/application/workflows/dailypaper.py Adds publishable URL filter and inline fallback for main figure; passes MinerU client params.
src/paperbot/application/services/email_template.py Adds main-figure block rendering supporting URL or inline data URL.
src/paperbot/api/routes/studio_chat.py Adds structured CLI streaming (stream-json), optional context-pack materialization into workspace, and keepalive heartbeats.
src/paperbot/api/routes/runbook.py Adjusts runtime allowlist mutation toggle default and refines path normalization/denies.
src/paperbot/api/routes/repro_context.py Allows inline title/abstract bypassing router lookup; adds persistence error handling and better error events.
env.example Documents MinerU v4 settings and DailyPaper figure toggles.
docs/proposals/agentswarm-design.md Adds AgentSwarm design proposal document.
README.md Updates Phase 6 / Daily push documentation and swaps email screenshot reference.
.gitignore Expands env file ignore patterns (while keeping .env.example).
Files not reviewed (1)
  • web/package-lock.json: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment thread web/e2e/papers.spec.ts Outdated
);
const count = await importBtn.count();
// At least some action button should be present
expect(count).toBeGreaterThanOrEqual(0);

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion is currently a no-op: count is always >= 0, so the test will pass even if no import/export controls exist. If the intent is to ensure an action is present, assert count > 0 (or check for a specific, stable locator).

Suggested change
expect(count).toBeGreaterThanOrEqual(0);
expect(count).toBeGreaterThan(0);

Copilot uses AI. Check for mistakes.
Comment thread src/paperbot/api/routes/runbook.py Outdated

def _runtime_allowlist_mutation_enabled() -> bool:
return os.getenv("PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION", "false").lower() == "true"
return os.getenv("PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION", "true").lower() == "true"

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION now defaults to "true", which enables runtime allowlist mutation in all environments unless explicitly disabled. This expands the attack surface (an unauthenticated/misconfigured deployment could allow arbitrary directories to be added for file operations). Consider defaulting back to "false" and only enabling this endpoint explicitly in trusted/dev setups (or require auth).

Suggested change
return os.getenv("PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION", "true").lower() == "true"
return os.getenv("PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION", "false").lower() == "true"

Copilot uses AI. Check for mistakes.
Comment on lines 384 to 390
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env={**os.environ, "FORCE_COLOR": "0"}, # Disable ANSI colors
env={**os.environ, "FORCE_COLOR": "0"},
)

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Claude CLI subprocess sets stderr=PIPE but stderr is never drained while the process runs (only read after exit on non-zero return code). If claude writes enough to stderr (likely with --verbose), this can deadlock the subprocess. Consider redirecting stderr to stdout, or concurrently reading/storing stderr in a background task while streaming stdout.

Copilot uses AI. Check for mistakes.

if inline_data_url.startswith("data:image/"):
image_src = inline_data_url
elif url.startswith(("http://", "https://")) and ".zip#" not in url and not url.endswith(".zip"):

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_main_figure_html filters out .zip# and URLs ending with .zip, but it does not exclude presigned zip URLs that include query strings (e.g. result.zip?token=...#/images/...). Those are still zip artifacts and won't render as images. Consider reusing the same publishable-URL check as dailypaper._is_publishable_figure_url (or extend this condition to also reject .zip? / .zip# variants).

Suggested change
elif url.startswith(("http://", "https://")) and ".zip#" not in url and not url.endswith(".zip"):
elif (
url.startswith(("http://", "https://"))
and ".zip#" not in url
and ".zip?" not in url
and not url.endswith(".zip")
):

Copilot uses AI. Check for mistakes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
web/src/lib/store/project-context.ts (1)

21-25: ⚠️ Potential issue | 🟡 Minor

Add tests for project-context store initialization and state handling.

The useProjectContext store is used by multiple components (DeepCodeEditor, FilesPanel, ReproductionLog, WorkspacePanel) and initializes with empty files and null activeFile. Per coding guidelines, this store behavior should have unit tests to verify initialization and ensure consumers handle the empty/null state correctly, similar to the pattern in studio-store.test.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/lib/store/project-context.ts` around lines 21 - 25, Add unit tests
that assert useProjectContext initializes correctly: verify the store's files
equals DEFAULT_FILES (empty record) and activeFile is null, and that consumers
(DeepCodeEditor, FilesPanel, ReproductionLog, WorkspacePanel) handle these
empty/null values by rendering their fallback states; specifically create tests
mirroring the pattern in studio-store.test.ts that (1) creates/reads the store
via useProjectContext(), (2) asserts files === DEFAULT_FILES and activeFile ===
null, and (3) mounts or renders the consumer components to assert they show the
"ready/empty" UI paths when files is empty and activeFile is null.
🧹 Nitpick comments (7)
web/src/lib/store/project-context.ts (1)

35-40: Add defensive check in updateFile to prevent creating invalid file objects.

If updateFile is called with a name that doesn't exist in files, spreading undefined ({ ...state.files[name], content }) produces { content } — an object missing the required name and language properties, violating the VirtualFile interface.

🛡️ Proposed defensive fix
 updateFile: (name, content) => set((state) => {
+    if (!state.files[name]) return state
     return {
         files: {
             ...state.files,
             [name]: { ...state.files[name], content }
-        }
+        },
     }
 }),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/lib/store/project-context.ts` around lines 35 - 40, The updateFile
setter currently spreads state.files[name] even when it may be undefined,
producing an object missing required VirtualFile fields; modify updateFile to
first check whether state.files[name] exists in the current state.files map and
abort (return the unchanged state) or throw a clear error if it does not, rather
than creating a new object, so you don’t end up with { content } lacking name
and language; reference the updateFile function and state.files/VirtualFile when
making this guard.
tests/unit/test_daily_digest_card.py (1)

157-181: Add one URL-branch and one ZIP-rejection assertion test.

Nice inline-path coverage. Please add companion cases for main_figure.url (valid HTTP image) and a rejected zip-style URL (.zip?/.zip#) to lock renderer behavior.

As per coding guidelines, "If behavior changes, add or update tests."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/test_daily_digest_card.py` around lines 157 - 181, Extend the
existing test_main_figure_inline_in_email_html scenario by adding two additional
assertions/cases: construct a report via build_daily_paper_report and set
item["main_figure"]["url"] to a valid HTTP(S) image URL and assert
build_digest_html(report) includes that URL and renders the expected
caption/text, and then set item["main_figure"]["url"] to a zip-like URL
containing ".zip?" or ".zip#" and assert build_digest_html(report) does NOT
embed or accept that URL (i.e., renderer rejects it); locate and update tests
around build_daily_paper_report, build_digest_html, and the
test_main_figure_inline_in_email_html function to add these two branches.
web/src/lib/store/studio-store.ts (1)

262-298: Per-paper state caching works correctly, but streaming operations may continue after paper switch.

The cache save/restore logic is sound. However, based on ReproductionLog.tsx (lines 280-395), when a user switches papers mid-stream, the for await loop in handleSendMessageWithDir continues running with a captured taskId. Since tasks are global, appends still target the correct task, but there's no AbortController to cancel the fetch when selectPaper is called.

Consider adding a cleanup mechanism (e.g., store an AbortController per active stream and abort it in selectPaper) to prevent background streams from consuming resources and potentially causing confusing state updates.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/lib/store/studio-store.ts` around lines 262 - 298, selectPaper needs
to cancel any in-flight streaming fetches before switching papers: add a
per-stream AbortController map on the store (e.g., _streamControllers keyed by
taskId or selectedPaperId), update handleSendMessageWithDir to create and
register an AbortController and pass its signal into its fetch/stream loop, and
ensure the streaming logic removes its controller when the stream completes or
errors; finally, in selectPaper (before writing _paperCache/selectedPaperId)
iterate any controllers related to the leaving paper or activeTaskId and call
abort() and remove them so background streams are cancelled when switching
papers.
web/src/lib/store/studio-store.test.ts (1)

1-1: Remove unnecessary "use client" directive from test file.

The "use client" directive is a Next.js convention for client components. Test files run in Vitest/Node and don't need this directive. It's harmless but misleading.

Suggested fix
-"use client"
-
 import { beforeEach, describe, expect, it } from "vitest"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/lib/store/studio-store.test.ts` at line 1, Remove the unnecessary
"use client" directive from the test file by deleting the top-level "use client"
string in the studio-store.test file; tests run under Vitest/Node and should not
include Next.js client component directives, so simply remove that line to avoid
confusion.
tests/unit/test_mineru_client.py (1)

20-41: Consider using pytest.raises for cleaner exception testing.

The assert False pattern works but pytest.raises is more idiomatic and won't be stripped by python -O. This addresses the static analysis warning.

♻️ Suggested refactor using pytest.raises
 def test_validate_source_url_requires_http():
     client = MineruClient(api_key="test-key")
-    try:
-        client._validate_source_url("file:///tmp/a.pdf")
-        assert False, "expected ValueError for non-http source"
-    except ValueError as exc:
-        assert "http(s)" in str(exc)
+    with pytest.raises(ValueError, match=r"http\(s\)"):
+        client._validate_source_url("file:///tmp/a.pdf")


 def test_validate_source_url_rejects_github_and_aws():
     client = MineruClient(api_key="test-key")
     for url in (
         "https://github.com/a/b/raw/main/paper.pdf",
         "https://raw.githubusercontent.com/a/b/main/paper.pdf",
         "https://bucket.s3.amazonaws.com/paper.pdf",
     ):
-        try:
-            client._validate_source_url(url)
-            assert False, f"expected ValueError for URL: {url}"
-        except ValueError as exc:
-            assert "github/aws" in str(exc)
+        with pytest.raises(ValueError, match="github/aws"):
+            client._validate_source_url(url)

Add import pytest at the top if not already present.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/test_mineru_client.py` around lines 20 - 41, Replace the manual
try/except + assert False pattern in test_validate_source_url_requires_http and
test_validate_source_url_rejects_github_and_aws with pytest.raises for cleaner,
robust exception assertions: add "import pytest" if missing, use "with
pytest.raises(ValueError) as exc:" around the call to
MineruClient._validate_source_url(...) and then assert the expected substring
appears in str(exc.value) (e.g., "http(s)" and "github/aws") to preserve the
existing checks.
web/src/components/studio/NewPaperModal.tsx (1)

118-150: Consider clearing error state when switching tabs.

The error state is shared between both tabs but only cleared in resetAndClose. If a user triggers a duplicate error in the library tab, then switches to the manual tab, the error message will still be visible. Consider clearing the error when the tab changes.

♻️ Suggested improvement

Add an onValueChange handler to the Tabs component:

<Tabs defaultValue="library" className="w-full" onValueChange={() => setError(null)}>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/studio/NewPaperModal.tsx` around lines 118 - 150, The
error state (error / setError) stays set when switching tabs because it’s only
cleared in resetAndClose; update the Tabs usage (component named Tabs) to clear
the error on tab change by adding an onValueChange handler that calls
setError(null) (or otherwise reset the error). Alternatively, handle tab
switches in the component’s tab-change callback to call setError(null) so
manual/library tab switches clear the shared error state; ensure this integrates
with the existing resetAndClose and any other places that set error.
src/paperbot/infrastructure/extractors/mineru_client.py (1)

160-186: Consider adding jitter to the polling interval.

The fixed polling interval could cause thundering herd issues if many tasks are polled simultaneously. Adding a small random jitter would help distribute load.

♻️ Suggested improvement
+import random
+
 def _poll_until_done(
     self,
     client: httpx.Client,
     *,
     headers: Dict[str, str],
     task_id: str,
 ) -> Dict[str, Any]:
     deadline = time.time() + self._max_wait_seconds
     while time.time() <= deadline:
         body = self._request_json(...)
         detail = body.get("data") or {}
         state = str(detail.get("state") or "").strip().lower()

         if state == "done":
             return detail
         if state == "failed":
             err_msg = str(detail.get("err_msg") or "task failed")
             raise RuntimeError(err_msg)

-        time.sleep(self._poll_interval_seconds)
+        jitter = random.uniform(0, self._poll_interval_seconds * 0.2)
+        time.sleep(self._poll_interval_seconds + jitter)

     raise TimeoutError(f"MinerU task timed out after {self._max_wait_seconds:.0f}s")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/paperbot/infrastructure/extractors/mineru_client.py` around lines 160 -
186, The polling loop in _poll_until_done uses a fixed sleep
(time.sleep(self._poll_interval_seconds)) which can cause thundering-herd
issues; modify _poll_until_done to add a small random jitter to the sleep
duration (e.g., sample jitter = random.uniform(-j, +j) or random.uniform(0, j)
and add to self._poll_interval_seconds), clamp to a positive minimum, and use
that jittered value for time.sleep; import random at the top and keep behavior
bounded relative to _poll_interval_seconds and _max_wait_seconds so timeout
logic remains correct.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/paperbot/application/services/email_template.py`:
- Around line 167-168: The URL guard should be case-insensitive and also exclude
query-form zip URLs; convert the URL to lower case (e.g., url_lower =
url.lower()) and change the condition in the branch that sets image_src so it
only allows http/https when url_lower startswith("http://"/"https://") and does
not contain ".zip?" or ".zip#" and does not endwith(".zip"); update the check
around the existing url and image_src variables accordingly to reject .zip,
.zip? and .zip# (case-insensitive).

In `@web/e2e/papers.spec.ts`:
- Around line 19-27: The test "papers page has import/export options" currently
uses importBtn and count but asserts count >= 0 which always passes; update the
test to actually verify the UI: either change the assertion to
expect(count).toBeGreaterThan(0) on the correct selectors (note that "BibTeX"
and "Zotero" are DropdownMenuItem elements, not buttons, so target their menu
item selectors) or implement the UI flow by clicking the dropdown trigger / menu
toggle to open the menu, then locate the "BibTeX" and "Zotero" items and assert
they are visible, and for the "Import" action open the import dialog (click the
import trigger) before asserting the import button exists; adjust the locator
usage (replace button:has-text with the appropriate dropdown/menu item selectors
and the dialog trigger) and replace the ineffective
expect(count).toBeGreaterThanOrEqual(0) with a strict existence/visibility
assertion.

In `@web/src/components/studio/PaperGallery.tsx`:
- Around line 97-118: The fetch call to "/api/runbook/delete" is sending only {
project_dir: paperToDelete.outputDir } but the API requires both project_dir and
path and rejects directory deletion; update the deletion logic around
paperToDelete.outputDir and the fetch("/api/runbook/delete") call to supply a
valid `path` (not a directory) for each file you want removed—either iterate
over each generated file and POST { project_dir, path } per file or call a new
backend endpoint that accepts a directory and performs recursive deletion; also
preserve existing error handling (e.g., setDeleteError) when requests fail.

In `@web/src/components/studio/PaperIcon.tsx`:
- Around line 1-104: Add unit tests covering getAbbreviation and PaperIcon
deterministic output: create tests that call getAbbreviation with empty string,
title containing only stop-words, and Unicode/title with punctuation to assert
expected two-letter outputs (reference function getAbbreviation), and add a
snapshot or string-compare test that renders PaperIcon with a fixed paperId and
title and verifies the SVG output is stable (reference component PaperIcon and
djb2-derived coloring/pattern). Ensure tests import getAbbreviation and
PaperIcon from PaperIcon.tsx, exercise size variations if relevant, and include
assertions for color/pattern or full SVG string equality to guarantee
determinism.
- Around line 29-37: The getAbbreviation function currently strips all non-ASCII
characters; change the cleanup regex to keep Unicode letters and numbers (e.g.
replace(/[^\\p{L}\\p{N}\\s]/gu, "") ) so non‑English letters are preserved, and
when taking the first N characters use a codepoint-aware approach (e.g. use
Array.from(...) or spread [...word] instead of slice on the string) to avoid
breaking multi‑byte characters; update logic in getAbbreviation and ensure
SKIP_WORDS checks still use the same lowercasing (use w.toLowerCase()) so
comparisons work with preserved Unicode letters.
- Around line 45-70: PaperIcon currently uses the incoming size directly which
can be 0, negative, or NaN and will break SVG geometry; before any use of size
(and before computing cellSize) validate and clamp it to a safe positive number
(e.g., if size is not finite or <= 0 fallback to 48, or clamp into a min/max
range) and then use this sanitizedSize for width/height, viewBox and cellSize
calculations so all downstream geometry (cellSize, SVG attributes) is always
valid; update references of size to the sanitized variable within PaperIcon
(keep function name PaperIcon and variables cellSize, bits, cells the same).

---

Outside diff comments:
In `@web/src/lib/store/project-context.ts`:
- Around line 21-25: Add unit tests that assert useProjectContext initializes
correctly: verify the store's files equals DEFAULT_FILES (empty record) and
activeFile is null, and that consumers (DeepCodeEditor, FilesPanel,
ReproductionLog, WorkspacePanel) handle these empty/null values by rendering
their fallback states; specifically create tests mirroring the pattern in
studio-store.test.ts that (1) creates/reads the store via useProjectContext(),
(2) asserts files === DEFAULT_FILES and activeFile === null, and (3) mounts or
renders the consumer components to assert they show the "ready/empty" UI paths
when files is empty and activeFile is null.

---

Nitpick comments:
In `@src/paperbot/infrastructure/extractors/mineru_client.py`:
- Around line 160-186: The polling loop in _poll_until_done uses a fixed sleep
(time.sleep(self._poll_interval_seconds)) which can cause thundering-herd
issues; modify _poll_until_done to add a small random jitter to the sleep
duration (e.g., sample jitter = random.uniform(-j, +j) or random.uniform(0, j)
and add to self._poll_interval_seconds), clamp to a positive minimum, and use
that jittered value for time.sleep; import random at the top and keep behavior
bounded relative to _poll_interval_seconds and _max_wait_seconds so timeout
logic remains correct.

In `@tests/unit/test_daily_digest_card.py`:
- Around line 157-181: Extend the existing test_main_figure_inline_in_email_html
scenario by adding two additional assertions/cases: construct a report via
build_daily_paper_report and set item["main_figure"]["url"] to a valid HTTP(S)
image URL and assert build_digest_html(report) includes that URL and renders the
expected caption/text, and then set item["main_figure"]["url"] to a zip-like URL
containing ".zip?" or ".zip#" and assert build_digest_html(report) does NOT
embed or accept that URL (i.e., renderer rejects it); locate and update tests
around build_daily_paper_report, build_digest_html, and the
test_main_figure_inline_in_email_html function to add these two branches.

In `@tests/unit/test_mineru_client.py`:
- Around line 20-41: Replace the manual try/except + assert False pattern in
test_validate_source_url_requires_http and
test_validate_source_url_rejects_github_and_aws with pytest.raises for cleaner,
robust exception assertions: add "import pytest" if missing, use "with
pytest.raises(ValueError) as exc:" around the call to
MineruClient._validate_source_url(...) and then assert the expected substring
appears in str(exc.value) (e.g., "http(s)" and "github/aws") to preserve the
existing checks.

In `@web/src/components/studio/NewPaperModal.tsx`:
- Around line 118-150: The error state (error / setError) stays set when
switching tabs because it’s only cleared in resetAndClose; update the Tabs usage
(component named Tabs) to clear the error on tab change by adding an
onValueChange handler that calls setError(null) (or otherwise reset the error).
Alternatively, handle tab switches in the component’s tab-change callback to
call setError(null) so manual/library tab switches clear the shared error state;
ensure this integrates with the existing resetAndClose and any other places that
set error.

In `@web/src/lib/store/project-context.ts`:
- Around line 35-40: The updateFile setter currently spreads state.files[name]
even when it may be undefined, producing an object missing required VirtualFile
fields; modify updateFile to first check whether state.files[name] exists in the
current state.files map and abort (return the unchanged state) or throw a clear
error if it does not, rather than creating a new object, so you don’t end up
with { content } lacking name and language; reference the updateFile function
and state.files/VirtualFile when making this guard.

In `@web/src/lib/store/studio-store.test.ts`:
- Line 1: Remove the unnecessary "use client" directive from the test file by
deleting the top-level "use client" string in the studio-store.test file; tests
run under Vitest/Node and should not include Next.js client component
directives, so simply remove that line to avoid confusion.

In `@web/src/lib/store/studio-store.ts`:
- Around line 262-298: selectPaper needs to cancel any in-flight streaming
fetches before switching papers: add a per-stream AbortController map on the
store (e.g., _streamControllers keyed by taskId or selectedPaperId), update
handleSendMessageWithDir to create and register an AbortController and pass its
signal into its fetch/stream loop, and ensure the streaming logic removes its
controller when the stream completes or errors; finally, in selectPaper (before
writing _paperCache/selectedPaperId) iterate any controllers related to the
leaving paper or activeTaskId and call abort() and remove them so background
streams are cancelled when switching papers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2c2e8927-8445-4b4f-8fbc-9feb02295205

📥 Commits

Reviewing files that changed from the base of the PR and between 9103f6e and b05a1ba.

⛔ Files ignored due to path filters (2)
  • asset/ui/dailypaperdemo.png is excluded by !**/*.png
  • web/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (33)
  • .gitignore
  • README.md
  • docs/proposals/agentswarm-design.md
  • env.example
  • src/paperbot/api/routes/repro_context.py
  • src/paperbot/api/routes/runbook.py
  • src/paperbot/api/routes/studio_chat.py
  • src/paperbot/application/services/email_template.py
  • src/paperbot/application/workflows/dailypaper.py
  • src/paperbot/infrastructure/extractors/mineru_client.py
  • src/paperbot/infrastructure/queue/arq_worker.py
  • src/paperbot/presentation/cli/main.py
  • tests/unit/test_arq_daily_papers.py
  • tests/unit/test_daily_digest_card.py
  • tests/unit/test_dailypaper.py
  • tests/unit/test_mineru_client.py
  • web/.gitignore
  • web/e2e/papers.spec.ts
  • web/e2e/research.spec.ts
  • web/e2e/smoke.spec.ts
  • web/next.config.ts
  • web/package.json
  • web/playwright.config.ts
  • web/src/app/studio/page.tsx
  • web/src/components/studio/NewPaperModal.tsx
  • web/src/components/studio/PaperGallery.tsx
  • web/src/components/studio/PaperIcon.tsx
  • web/src/components/studio/ReproductionLog.tsx
  • web/src/hooks/useContextPackGeneration.ts
  • web/src/lib/store/project-context.ts
  • web/src/lib/store/studio-store.test.ts
  • web/src/lib/store/studio-store.ts
  • web/vitest.config.ts

Comment thread src/paperbot/application/services/email_template.py Outdated
Comment thread web/e2e/papers.spec.ts
Comment thread web/src/components/studio/PaperGallery.tsx
Comment on lines +1 to +104
"use client"

/**
* Deterministic geometric icon for papers.
* Hashes the paper ID (djb2) to derive a color and 4×4 grid pattern,
* producing a blocky identicon-like SVG. Also extracts a 2-letter
* abbreviation from the title.
*/

const PALETTE = [
"#e63946", "#457b9d", "#2a9d8f", "#e9c46a",
"#f4a261", "#264653", "#6a4c93", "#1982c4",
"#8ac926", "#ff595e", "#6d6875", "#3a86a7",
]

function djb2(str: string): number {
let hash = 5381
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0
}
return hash
}

const SKIP_WORDS = new Set([
"a", "an", "the", "of", "in", "on", "at", "to", "for",
"and", "or", "is", "by", "with", "from", "as", "via",
])

function getAbbreviation(title: string): string {
const words = title
.replace(/[^a-zA-Z0-9\s]/g, "")
.split(/\s+/)
.filter(w => w.length > 0 && !SKIP_WORDS.has(w.toLowerCase()))
if (words.length === 0) return "??"
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
return (words[0][0] + words[1][0]).toUpperCase()
}

interface PaperIconProps {
paperId: string
title: string
size?: number
}

export function PaperIcon({ paperId, title, size = 48 }: PaperIconProps) {
const hash = djb2(paperId)
const color = PALETTE[hash % PALETTE.length]
const abbr = getAbbreviation(title)

// Generate a 4×4 symmetric pattern (mirror left half → right half)
// Use 8 bits from the hash for 2×4 cells, then mirror horizontally
const bits = (hash >>> 8) & 0xffff
const cellSize = size / 4

const cells: { x: number; y: number }[] = []
for (let row = 0; row < 4; row++) {
for (let col = 0; col < 2; col++) {
const bitIndex = row * 2 + col
if ((bits >> bitIndex) & 1) {
cells.push({ x: col, y: row })
cells.push({ x: 3 - col, y: row }) // mirror
}
}
}

return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label={abbr}
>
<rect width={size} height={size} rx={size * 0.15} fill={color} opacity={0.15} />
{cells.map((cell, i) => (
<rect
key={i}
x={cell.x * cellSize + cellSize * 0.1}
y={cell.y * cellSize + cellSize * 0.1}
width={cellSize * 0.8}
height={cellSize * 0.8}
rx={cellSize * 0.15}
fill={color}
/>
))}
<text
x={size / 2}
y={size / 2}
textAnchor="middle"
dominantBaseline="central"
fill="white"
fontSize={size * 0.3}
fontWeight="bold"
fontFamily="system-ui, sans-serif"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.3)" }}
>
{abbr}
</text>
</svg>
)
}

export { getAbbreviation }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add tests for this new behavior and exported helper.

This file adds new deterministic rendering behavior and exports getAbbreviation, but no test updates are included in the provided changes. Please add tests for at least: abbreviation edge cases (empty/stop-word-only/unicode) and deterministic SVG output for a fixed paperId.

As per coding guidelines, "{src,web/src}/**/*.{py,ts,tsx}: If behavior changes, add or update tests".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/studio/PaperIcon.tsx` around lines 1 - 104, Add unit tests
covering getAbbreviation and PaperIcon deterministic output: create tests that
call getAbbreviation with empty string, title containing only stop-words, and
Unicode/title with punctuation to assert expected two-letter outputs (reference
function getAbbreviation), and add a snapshot or string-compare test that
renders PaperIcon with a fixed paperId and title and verifies the SVG output is
stable (reference component PaperIcon and djb2-derived coloring/pattern). Ensure
tests import getAbbreviation and PaperIcon from PaperIcon.tsx, exercise size
variations if relevant, and include assertions for color/pattern or full SVG
string equality to guarantee determinism.

Comment thread web/src/components/studio/PaperIcon.tsx
Comment thread web/src/components/studio/PaperIcon.tsx Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/paperbot/api/routes/runbook.py`:
- Around line 216-217: The function _runtime_allowlist_mutation_enabled
currently defaults to true; change its default to "false" so the code reads
os.getenv("PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION", "false").lower() == "true" to
preserve opt-in semantics, and add a unit test (e.g.,
test_runtime_allowlist_mutation_disabled) that sets
PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION="false" in the environment, calls
_runtime_allowlist_mutation_enabled(), and asserts False; if this behavior was
intentional instead, add documentation noting the breaking change and a test
that verifies enabled behavior when set to "true".

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9e4aa64d-5a72-42ec-908d-40ee74d0f530

📥 Commits

Reviewing files that changed from the base of the PR and between b05a1ba and 071d936.

📒 Files selected for processing (1)
  • src/paperbot/api/routes/runbook.py

Comment thread src/paperbot/api/routes/runbook.py Outdated
Comment on lines +216 to +217
def _runtime_allowlist_mutation_enabled() -> bool:
return os.getenv("PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION", "false").lower() == "true"
return os.getenv("PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION", "true").lower() == "true"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Security posture change: allowlist mutation now enabled by default.

Changing the default from "false" to "true" inverts the security model from opt-in to opt-out. Existing deployments relying on the implicit default will now permit runtime allowlist mutations without explicit configuration.

Consider:

  1. Keeping the default as "false" to maintain opt-in security
  2. If intentional, documenting this breaking change and adding a test that verifies the disabled behavior when PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION=false
Suggested fix (preserve opt-in default)
 def _runtime_allowlist_mutation_enabled() -> bool:
-    return os.getenv("PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION", "true").lower() == "true"
+    return os.getenv("PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION", "false").lower() == "true"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _runtime_allowlist_mutation_enabled() -> bool:
return os.getenv("PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION", "false").lower() == "true"
return os.getenv("PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION", "true").lower() == "true"
def _runtime_allowlist_mutation_enabled() -> bool:
return os.getenv("PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION", "false").lower() == "true"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/paperbot/api/routes/runbook.py` around lines 216 - 217, The function
_runtime_allowlist_mutation_enabled currently defaults to true; change its
default to "false" so the code reads
os.getenv("PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION", "false").lower() == "true" to
preserve opt-in semantics, and add a unit test (e.g.,
test_runtime_allowlist_mutation_disabled) that sets
PAPERBOT_RUNBOOK_ALLOWLIST_MUTATION="false" in the environment, calls
_runtime_allowlist_mutation_enabled(), and asserts False; if this behavior was
intentional instead, add documentation noting the breaking change and a test
that verifies enabled behavior when set to "true".

Copilot AI review requested due to automatic review settings March 4, 2026 09:17
Comment thread src/paperbot/api/routes/studio_chat.py Fixed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 36 out of 39 changed files in this pull request and generated 3 comments.

Files not reviewed (1)
  • web/package-lock.json: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines 50 to 56
selectPaper,
loadPapers,
papers,
selectedPaperId,
setContextPack,
setContextPackLoading,
setContextPackError,

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The URL-param processing effect in this component relies on papers being loaded from loadPapers(), but loadPapers() is called in a separate useEffect and its state update won’t be visible to the param effect on the first effect flush. This can cause existingPaper checks to miss stored papers (creating duplicates) and, when paper_id isn’t found, addPaper(...)’s returned id is ignored while generate({ paperId }) still uses the query-string id (which won’t match the newly created paper). Consider gating param processing on a “papers loaded” flag, and/or capturing the id returned by addPaper() and using it for selectPaper/generate (optionally passing title/abstract into generate for the studio-ID case).

Copilot uses AI. Check for mistakes.
Comment on lines +193 to +197
onClick={(e) =>
handleDeleteClick(e, paper)
}
className="absolute right-2 top-2 z-10 rounded-md p-1.5 opacity-0 group-hover:opacity-100 hover:bg-destructive/10 hover:text-destructive transition-all"
title="Delete paper"

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The delete icon-only <button> relies on title and is visually hidden via opacity-0 until hover. For accessibility, add an explicit aria-label (or screen-reader-only text) and ensure it becomes visible on keyboard focus as well (e.g., group-focus-within:opacity-100 / focus-visible:opacity-100) so keyboard and screen-reader users can discover/operate it.

Suggested change
onClick={(e) =>
handleDeleteClick(e, paper)
}
className="absolute right-2 top-2 z-10 rounded-md p-1.5 opacity-0 group-hover:opacity-100 hover:bg-destructive/10 hover:text-destructive transition-all"
title="Delete paper"
type="button"
onClick={(e) =>
handleDeleteClick(e, paper)
}
className="absolute right-2 top-2 z-10 rounded-md p-1.5 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 transition-all"
title="Delete paper"
aria-label="Delete paper"

Copilot uses AI. Check for mistakes.
Comment on lines 460 to +469
if enable_figures:
mineru_key = os.getenv("MINERU_API_KEY", "")
if mineru_key:
report = extract_figures_for_report(
report,
api_key=mineru_key,
max_items=max(1, int(figures_max_items)),
base_url=mineru_api_base_url,
model_version=mineru_model_version,
max_wait_seconds=max(5.0, float(mineru_max_wait_seconds)),

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

daily_papers_job is async, but extract_figures_for_report(...) is synchronous and (via MineruClient) performs blocking HTTP calls and polling with time.sleep. This will block the event loop for up to mineru_max_wait_seconds per item, potentially stalling other async work in the worker.

Consider running figure extraction in asyncio.to_thread(...) (quick fix), or converting MinerU extraction to use httpx.AsyncClient + asyncio.sleep so the job remains non-blocking.

Copilot uses AI. Check for mistakes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
web/src/components/studio/PapersPanel.tsx (1)

119-129: ⚠️ Potential issue | 🟠 Major

Do not delete the paper record when generated-file cleanup fails.

At Line 121-Line 124, cleanup errors are swallowed and Line 128 still removes the paper. This can leave orphaned files while the UI implies cleanup succeeded.

Suggested fix
     setDeleting(true)
     try {
       // Delete generated files if outputDir exists
       if (paperToDelete.outputDir) {
-        try {
-          await deleteProjectFiles(paperToDelete.outputDir)
-        } catch (e) {
-          console.error('Failed to delete project files:', e)
-        }
+        await deleteProjectFiles(paperToDelete.outputDir)
       }

       // Delete paper from store
       deletePaper(paperToDelete.id)
+      setDeleteConfirmOpen(false)
+      setPaperToDelete(null)
+    } catch (e) {
+      console.error("Failed to delete paper and project files:", e)
     } finally {
       setDeleting(false)
-      setDeleteConfirmOpen(false)
-      setPaperToDelete(null)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/studio/PapersPanel.tsx` around lines 119 - 129, The
current flow calls deletePaper(paperToDelete.id) regardless of whether
deleteProjectFiles(paperToDelete.outputDir) succeeded, causing the record to be
removed even if cleanup failed; modify the logic so that deletePaper is only
invoked when deleteProjectFiles completes without throwing: move the deletePaper
call into the try block after await deleteProjectFiles(...) (or check a success
flag), and ensure that exceptions from deleteProjectFiles are either rethrown or
cause early return so the paper record is not removed; use the existing
identifiers paperToDelete, deleteProjectFiles, and deletePaper to locate and
update the code.
♻️ Duplicate comments (1)
web/e2e/papers.spec.ts (1)

19-22: ⚠️ Potential issue | 🟠 Major

Export visibility check misses required precondition (selection).

On Line 20-Line 21, this asserts Export is visible before selecting any paper. In web/src/components/research/SavedPapersList.tsx (Line 254 and Line 794-Line 809), Export renders only when selectedIds.size > 0, so this test does not model the real UI flow.

Proposed test-flow fix
 test("papers page has import/export options", async ({ page }) => {
-  const exportButton = page.getByRole("button", { name: /export/i });
-  await expect(exportButton).toBeVisible();
+  // Precondition: select at least one paper so Export control is rendered
+  await page.getByRole("checkbox").first().check();
+
+  const exportButton = page.getByRole("button", { name: /export/i });
+  await expect(exportButton).toBeVisible();
+
+  await exportButton.click();
+  await expect(page.getByRole("menuitem", { name: /bibtex/i })).toBeVisible();
+  await expect(page.getByRole("menuitem", { name: /zotero/i })).toBeVisible();
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/e2e/papers.spec.ts` around lines 19 - 22, The test "papers page has
import/export options" asserts the Export button is visible without selecting
any paper, but Export in SavedPapersList.tsx is only rendered when
selectedIds.size > 0; update the test to first select at least one paper (e.g.,
locate and click a paper row or its selection checkbox using
page.getByRole/getByTestId) so selectedIds becomes non-empty, then assert the
exportButton is visible; keep the test name and use the existing exportButton
locator to verify visibility after selection.
🧹 Nitpick comments (6)
web/e2e/papers.spec.ts (1)

9-17: Tighten this assertion to avoid false positives.

On Line 10-Line 16, matching generic tokens like "Paper"/"Library" can pass on unrelated UI states. Prefer asserting a stable heading/landmark specific to the Papers page.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/e2e/papers.spec.ts` around lines 9 - 17, The current test "papers page
has saved papers section" is too broad by scanning body text for generic tokens;
replace that with a targeted assertion for a stable page landmark or heading
(e.g., assert page.getByRole('heading', { name: /Papers|Saved Papers|My
Library/i }) or a specific main/section aria-label exists and is visible) so the
test reliably checks the Papers page UI; update the assertion to locate the
heading/landmark (using getByRole or locator('main[aria-label="Papers"]') or
similar) and expect it toBeVisible()/toHaveCount(1) instead of checking body
text includes.
web/src/components/studio/NewPaperModal.tsx (1)

101-102: Extract a shared normalizeTitle helper to avoid normalization drift.

trim().toLowerCase() is repeated across manual submit, import, and rendering checks. Centralizing this reduces accidental inconsistency later.

♻️ Suggested refactor
+const normalizeTitle = (value: string) => value.trim().toLowerCase()

-const normalizedTitle = title.trim().toLowerCase()
-const duplicate = papers.find(p => p.title.trim().toLowerCase() === normalizedTitle)
+const normalizedTitle = normalizeTitle(title)
+const duplicate = papers.find(p => normalizeTitle(p.title) === normalizedTitle)

-const existingTitles = new Set(papers.map(p => p.title.trim().toLowerCase()))
+const existingTitles = new Set(papers.map(p => normalizeTitle(p.title)))

-const normalizedTitle = paper.title.trim().toLowerCase()
+const normalizedTitle = normalizeTitle(paper.title)

-const studioPaperTitles = new Set(papers.map(p => p.title.trim().toLowerCase()))
+const studioPaperTitles = new Set(papers.map(p => normalizeTitle(p.title)))

-const isInStudio = studioPaperTitles.has(paper.title.trim().toLowerCase())
+const isInStudio = studioPaperTitles.has(normalizeTitle(paper.title))

Also applies to: 118-118, 127-127, 175-175, 227-229

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/studio/NewPaperModal.tsx` around lines 101 - 102, Extract
a single helper function (e.g., normalizeTitle) in NewPaperModal.tsx that
returns title.trim().toLowerCase(), and replace all ad-hoc normalizations (the
normalizedTitle variable and the papers.find comparison: papers.find(p =>
p.title.trim().toLowerCase() === normalizedTitle)) as well as the other
occurrences flagged (uses around handling submit/import and rendering checks) to
call normalizeTitle(title) and normalizeTitle(p.title) so all duplicate checks
and render logic use the centralized function.
src/paperbot/api/routes/studio_chat.py (2)

133-150: Consider extracting shared path-loading logic and adding debug logging for swallowed exceptions.

This function duplicates logic from runbook.py:_load_runtime_allowed_dirs (see context snippet 4). The runbook version also tries multiple potential paths as fallbacks. Additionally, exceptions caught at lines 148-149 are silently swallowed—adding debug-level logging would aid troubleshooting.

♻️ Suggested improvement
 def _load_runtime_allowed_dirs() -> List[Path]:
-    f = Path("data/runbook_allowed_dirs.json")
-    if not f.exists():
-        return []
-    try:
-        data = json.loads(f.read_text(encoding="utf-8"))
-    except (OSError, json.JSONDecodeError, ValueError):
+    potential_paths = [
+        Path("data/runbook_allowed_dirs.json"),
+        Path(__file__).parent.parent.parent / "data" / "runbook_allowed_dirs.json",
+    ]
+    
+    for f in potential_paths:
+        if not f.exists():
+            continue
+        try:
+            data = json.loads(f.read_text(encoding="utf-8"))
+        except (OSError, json.JSONDecodeError, ValueError):
+            continue
         ...

Consider extracting this to a shared module (e.g., infrastructure/config/allowed_dirs.py) to eliminate duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/paperbot/api/routes/studio_chat.py` around lines 133 - 150, The
_load_runtime_allowed_dirs function duplicates logic from
runbook.py:_load_runtime_allowed_dirs and silently swallows exceptions; refactor
by extracting the shared path-loading logic into a single helper (e.g.,
infrastructure.config.allowed_dirs) and have both
studio_chat._load_runtime_allowed_dirs and runbook._load_runtime_allowed_dirs
call that helper, implement the runbook-style fallback over multiple candidate
files, and replace bare exception swallowing in the helper with debug-level
logging via the existing logger (log the exception message/traceback when
Path(...).resolve() or file reads/json loads fail) so failures are visible
during troubleshooting.

469-536: Consider adding an overall timeout to prevent indefinite hangs.

The keepalive mechanism handles temporary stdout silence, but if the Claude CLI process itself hangs indefinitely (e.g., deadlock, network partition), this loop will never terminate. Consider adding a maximum execution time:

♻️ Suggested improvement
+        _MAX_EXECUTION_SECONDS = 600  # 10 minutes max
+        start_time = asyncio.get_event_loop().time()
+
         # Read stdout line-by-line (NDJSON) with keepalive heartbeats.
         while True:
+            elapsed = asyncio.get_event_loop().time() - start_time
+            if elapsed > _MAX_EXECUTION_SECONDS:
+                process.kill()
+                yield StreamEvent(type="error", message="Claude CLI execution timed out")
+                break
+
             try:
                 chunk = await asyncio.wait_for(
                     process.stdout.read(4096),
                     timeout=_KEEPALIVE_SECONDS,
                 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/paperbot/api/routes/studio_chat.py` around lines 469 - 536, Add a global
maximum execution timeout around the CLI read/wait loop to avoid indefinite
hangs: introduce a deadline (e.g., MAX_EXEC_SECONDS) and check it in the main
loop that reads process.stdout (the block using _KEEPALIVE_SECONDS, line_buffer,
and asyncio.wait_for), or wrap the entire streaming coroutine in
asyncio.wait_for; on timeout, cancel/terminate the subprocess
(process.terminate()/process.kill()), await stderr_task and process.wait(), and
yield a final StreamEvent (type="error" or "progress" with timeout info and
request.mode) before returning so callers see the failure. Ensure cleanup still
runs (await stderr_task and process.wait()) and surface stderr_chunks for
debugging.
web/src/components/studio/PapersPanel.tsx (1)

113-134: Add tests for the new deletion workflow.

The confirm-delete behavior changed (new cleanup utility + error handling path), but I don’t see tests for success/failure branches in this change set.

As per coding guidelines, {src,web/src}/**/*.{py,ts,tsx}: If behavior changes, add or update tests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/studio/PapersPanel.tsx` around lines 113 - 134, The
deletion workflow in handleConfirmDelete now calls deleteProjectFiles and
handles errors, but there are no tests covering the success and failure
branches; add unit tests that exercise handleConfirmDelete (or the component
PapersPanel) to assert: (1) when paperToDelete has an outputDir and
deleteProjectFiles resolves, deleteProjectFiles is called with the correct path
and deletePaper is invoked with paperToDelete.id and UI state (setDeleting,
setDeleteConfirmOpen) updates appropriately; and (2) when deleteProjectFiles
rejects, it logs the error (mock console.error), still calls deletePaper, and UI
state resets; mock deleteProjectFiles and deletePaper, mount or render
PapersPanel, trigger the confirm action, and assert the expected calls and state
transitions for both success and error cases.
src/paperbot/api/routes/runbook.py (1)

137-173: Please add regression tests for the new path-normalization and allowlist-mutation behavior.

This change materially alters security-sensitive path handling (realpath normalization, denied-root checks, home-directory rejection). I don’t see corresponding route/unit tests in this change set for these branches.

As per coding guidelines, {src,web/src}/**/*.{py,ts,tsx}: If behavior changes, add or update tests.

Also applies to: 227-266

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/paperbot/api/routes/runbook.py` around lines 137 - 173, The change to
_normalize_user_directory introduces new security-sensitive branches (realpath
normalization, denied-root checks, '~' expansion, and allowlist-based path
mutation) but lacks tests; add regression/unit tests that call
_normalize_user_directory (and exercises behavior of _allowed_workdir_prefixes
and _is_under_prefix) to cover: empty input and NUL-char rejection; literal "~"
and "~/sub" expansion to Path.home(); relative paths resolved against cwd;
absolute paths that are allowed vs denied by the allowlist; a path that equals
an allowed prefix; a path under an allowed prefix that requires suffix
reconstruction and returns the mutated Path; and cases involving symlinked paths
where realpath differs (ensure denied-root and startswith checks behave as
expected). Ensure tests assert HTTPException status codes and exact error
messages for denied cases and that returned Path objects match expected
canonicalized prefixes when allowed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/paperbot/api/routes/runbook.py`:
- Around line 162-169: The check that gated child paths uses
"normalized_real.startswith(prefix_real + os.sep)" which fails when prefix_real
is root ("/"); change the conditional that decides children to handle the
root-prefix edge case by allowing any absolute path under "/" — e.g. replace
that condition with a combined check like "if prefix_real == os.sep or
normalized_real == prefix_real or normalized_real.startswith(prefix_real +
os.sep):" so the code still computes suffix and candidate as before and then
validates with _is_under_prefix(prefix, candidate).

In `@tests/unit/test_studio_chat_path_validation.py`:
- Around line 22-28: The test currently creates a persistent directory outside
the test sandbox; instead create the disallowed directory under the provided
tmp_path and then mock the source of allowed prefixes so the path is considered
"outside" allowed prefixes; e.g., create disallowed = tmp_path /
"paperbot-studio-chat-disallowed" and then monkeypatch the mechanism used to
compute allowed prefixes (or monkeypatch Path.home()/Path.home().parent) so that
disallowed is not within allowed prefixes, then call
_resolve_cli_project_dir(str(disallowed)) expecting ValueError; this keeps files
inside tmp_path and avoids permission/artifact issues.

In `@web/src/components/studio/NewPaperModal.tsx`:
- Around line 101-106: The PR added title-based dedup logic in NewPaperModal.tsx
(look for the NewPaperModal component and the normalizedTitle/duplicate check
that calls setError) and additional branches for manual duplicate rejection,
import-skip behavior, and singular vs plural error messages; add regression
tests that render NewPaperModal and simulate: submitting a title that differs
only by case/whitespace to assert the duplicate branch sets the correct error
message, the manual-reject path (simulate the user choosing "reject duplicate")
to assert the modal closes/sets state as expected, the import-skip flow to
assert it returns the expected skip result instead of error, and both
singular/plural message variations (1 vs >1 existing duplicates) to assert
correct wording; place tests alongside other web/src component tests (tsx test
files), use the component's public handlers (submit button, props) to drive the
scenarios, and assert on DOM error text and any emitted callbacks.

In `@web/src/lib/runbook/deleteProjectFiles.ts`:
- Around line 2-13: The file listing handler in deleteProjectFiles.ts currently
ignores the response's truncation flag and may proceed to delete only a subset;
update the code that parses listRes.json() (payload) to check for a boolean
payload.truncated (or equivalent) and, if true, abort and surface an error
instead of continuing; ensure the same function (where listRes, payload, and
files are defined) throws/reports a clear error when truncated is true so
deletions are not performed on a partial file list.

---

Outside diff comments:
In `@web/src/components/studio/PapersPanel.tsx`:
- Around line 119-129: The current flow calls deletePaper(paperToDelete.id)
regardless of whether deleteProjectFiles(paperToDelete.outputDir) succeeded,
causing the record to be removed even if cleanup failed; modify the logic so
that deletePaper is only invoked when deleteProjectFiles completes without
throwing: move the deletePaper call into the try block after await
deleteProjectFiles(...) (or check a success flag), and ensure that exceptions
from deleteProjectFiles are either rethrown or cause early return so the paper
record is not removed; use the existing identifiers paperToDelete,
deleteProjectFiles, and deletePaper to locate and update the code.

---

Duplicate comments:
In `@web/e2e/papers.spec.ts`:
- Around line 19-22: The test "papers page has import/export options" asserts
the Export button is visible without selecting any paper, but Export in
SavedPapersList.tsx is only rendered when selectedIds.size > 0; update the test
to first select at least one paper (e.g., locate and click a paper row or its
selection checkbox using page.getByRole/getByTestId) so selectedIds becomes
non-empty, then assert the exportButton is visible; keep the test name and use
the existing exportButton locator to verify visibility after selection.

---

Nitpick comments:
In `@src/paperbot/api/routes/runbook.py`:
- Around line 137-173: The change to _normalize_user_directory introduces new
security-sensitive branches (realpath normalization, denied-root checks, '~'
expansion, and allowlist-based path mutation) but lacks tests; add
regression/unit tests that call _normalize_user_directory (and exercises
behavior of _allowed_workdir_prefixes and _is_under_prefix) to cover: empty
input and NUL-char rejection; literal "~" and "~/sub" expansion to Path.home();
relative paths resolved against cwd; absolute paths that are allowed vs denied
by the allowlist; a path that equals an allowed prefix; a path under an allowed
prefix that requires suffix reconstruction and returns the mutated Path; and
cases involving symlinked paths where realpath differs (ensure denied-root and
startswith checks behave as expected). Ensure tests assert HTTPException status
codes and exact error messages for denied cases and that returned Path objects
match expected canonicalized prefixes when allowed.

In `@src/paperbot/api/routes/studio_chat.py`:
- Around line 133-150: The _load_runtime_allowed_dirs function duplicates logic
from runbook.py:_load_runtime_allowed_dirs and silently swallows exceptions;
refactor by extracting the shared path-loading logic into a single helper (e.g.,
infrastructure.config.allowed_dirs) and have both
studio_chat._load_runtime_allowed_dirs and runbook._load_runtime_allowed_dirs
call that helper, implement the runbook-style fallback over multiple candidate
files, and replace bare exception swallowing in the helper with debug-level
logging via the existing logger (log the exception message/traceback when
Path(...).resolve() or file reads/json loads fail) so failures are visible
during troubleshooting.
- Around line 469-536: Add a global maximum execution timeout around the CLI
read/wait loop to avoid indefinite hangs: introduce a deadline (e.g.,
MAX_EXEC_SECONDS) and check it in the main loop that reads process.stdout (the
block using _KEEPALIVE_SECONDS, line_buffer, and asyncio.wait_for), or wrap the
entire streaming coroutine in asyncio.wait_for; on timeout, cancel/terminate the
subprocess (process.terminate()/process.kill()), await stderr_task and
process.wait(), and yield a final StreamEvent (type="error" or "progress" with
timeout info and request.mode) before returning so callers see the failure.
Ensure cleanup still runs (await stderr_task and process.wait()) and surface
stderr_chunks for debugging.

In `@web/e2e/papers.spec.ts`:
- Around line 9-17: The current test "papers page has saved papers section" is
too broad by scanning body text for generic tokens; replace that with a targeted
assertion for a stable page landmark or heading (e.g., assert
page.getByRole('heading', { name: /Papers|Saved Papers|My Library/i }) or a
specific main/section aria-label exists and is visible) so the test reliably
checks the Papers page UI; update the assertion to locate the heading/landmark
(using getByRole or locator('main[aria-label="Papers"]') or similar) and expect
it toBeVisible()/toHaveCount(1) instead of checking body text includes.

In `@web/src/components/studio/NewPaperModal.tsx`:
- Around line 101-102: Extract a single helper function (e.g., normalizeTitle)
in NewPaperModal.tsx that returns title.trim().toLowerCase(), and replace all
ad-hoc normalizations (the normalizedTitle variable and the papers.find
comparison: papers.find(p => p.title.trim().toLowerCase() === normalizedTitle))
as well as the other occurrences flagged (uses around handling submit/import and
rendering checks) to call normalizeTitle(title) and normalizeTitle(p.title) so
all duplicate checks and render logic use the centralized function.

In `@web/src/components/studio/PapersPanel.tsx`:
- Around line 113-134: The deletion workflow in handleConfirmDelete now calls
deleteProjectFiles and handles errors, but there are no tests covering the
success and failure branches; add unit tests that exercise handleConfirmDelete
(or the component PapersPanel) to assert: (1) when paperToDelete has an
outputDir and deleteProjectFiles resolves, deleteProjectFiles is called with the
correct path and deletePaper is invoked with paperToDelete.id and UI state
(setDeleting, setDeleteConfirmOpen) updates appropriately; and (2) when
deleteProjectFiles rejects, it logs the error (mock console.error), still calls
deletePaper, and UI state resets; mock deleteProjectFiles and deletePaper, mount
or render PapersPanel, trigger the confirm action, and assert the expected calls
and state transitions for both success and error cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3b5a5e11-2916-4202-8d3e-35b8959097af

📥 Commits

Reviewing files that changed from the base of the PR and between 071d936 and 9fb8639.

📒 Files selected for processing (14)
  • src/paperbot/api/routes/runbook.py
  • src/paperbot/api/routes/studio_chat.py
  • src/paperbot/application/services/email_template.py
  • tests/unit/test_daily_digest_card.py
  • tests/unit/test_mineru_client.py
  • tests/unit/test_studio_chat_path_validation.py
  • web/e2e/papers.spec.ts
  • web/src/components/studio/NewPaperModal.tsx
  • web/src/components/studio/PaperGallery.tsx
  • web/src/components/studio/PaperIcon.test.tsx
  • web/src/components/studio/PaperIcon.tsx
  • web/src/components/studio/PapersPanel.tsx
  • web/src/lib/runbook/deleteProjectFiles.ts
  • web/src/lib/store/studio-store.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • web/src/components/studio/PaperGallery.tsx
  • web/src/lib/store/studio-store.test.ts
  • web/src/components/studio/PaperIcon.tsx

Comment on lines +162 to 169
prefix_real = os.path.realpath(prefix_str)
if normalized_real == prefix_real:
return prefix
if normalized.startswith(prefix_str + os.sep):
suffix = normalized[len(prefix_str):].lstrip("/\\")
if normalized_real.startswith(prefix_real + os.sep):
suffix = normalized_real[len(prefix_real):].lstrip("/\\")
candidate = (prefix / suffix).resolve(strict=False) if suffix else prefix
if _is_under_prefix(candidate, prefix):
return candidate

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Root-prefix edge case can reject valid paths.

At Line 165, normalized_real.startswith(prefix_real + os.sep) breaks when prefix_real is / (checks for //). That makes valid children under root fail normalization.

Suggested fix
-        if normalized_real.startswith(prefix_real + os.sep):
+        try:
+            under_prefix = os.path.commonpath([normalized_real, prefix_real]) == prefix_real
+        except ValueError:
+            under_prefix = False
+        if under_prefix and normalized_real != prefix_real:
             suffix = normalized_real[len(prefix_real):].lstrip("/\\")
             candidate = (prefix / suffix).resolve(strict=False) if suffix else prefix
-            if _is_under_prefix(candidate, prefix):
-                return candidate
-            raise HTTPException(status_code=403, detail=f"{field_name} is not allowed")
+            return candidate
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/paperbot/api/routes/runbook.py` around lines 162 - 169, The check that
gated child paths uses "normalized_real.startswith(prefix_real + os.sep)" which
fails when prefix_real is root ("/"); change the conditional that decides
children to handle the root-prefix edge case by allowing any absolute path under
"/" — e.g. replace that condition with a combined check like "if prefix_real ==
os.sep or normalized_real == prefix_real or
normalized_real.startswith(prefix_real + os.sep):" so the code still computes
suffix and candidate as before and then validates with _is_under_prefix(prefix,
candidate).

Comment on lines +22 to +28
def test_resolve_cli_project_dir_rejects_outside_allowed_prefixes(tmp_path: Path, monkeypatch):
monkeypatch.chdir(tmp_path)
disallowed = Path.home().parent / "paperbot-studio-chat-disallowed"
disallowed.mkdir(parents=True, exist_ok=True)

with pytest.raises(ValueError, match="not allowed"):
_resolve_cli_project_dir(str(disallowed))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Test creates directory outside tmp_path and never cleans it up.

Lines 24-25 create a directory at Path.home().parent / "paperbot-studio-chat-disallowed", which:

  1. Creates a persistent directory outside the test sandbox (e.g., /home/paperbot-studio-chat-disallowed)
  2. May fail with permission errors if Path.home().parent is root-owned
  3. Leaves artifacts after test completion

Per coding guidelines, use tmp_path fixture for temporary test directories.

🔧 Proposed fix using environment variable to create truly disallowed path
 def test_resolve_cli_project_dir_rejects_outside_allowed_prefixes(tmp_path: Path, monkeypatch):
-    monkeypatch.chdir(tmp_path)
-    disallowed = Path.home().parent / "paperbot-studio-chat-disallowed"
-    disallowed.mkdir(parents=True, exist_ok=True)
+    # Create two isolated directories - one as cwd, one as "disallowed"
+    cwd_dir = tmp_path / "cwd"
+    cwd_dir.mkdir()
+    disallowed = tmp_path / "disallowed"
+    disallowed.mkdir()
+
+    monkeypatch.chdir(cwd_dir)
+    # Clear any env overrides and runtime config to ensure only cwd is allowed
+    monkeypatch.setenv("PAPERBOT_RUNBOOK_ALLOW_DIR_PREFIXES", "")
+    monkeypatch.setattr(
+        "paperbot.api.routes.studio_chat._load_runtime_allowed_dirs",
+        lambda: [],
+    )
 
     with pytest.raises(ValueError, match="not allowed"):
         _resolve_cli_project_dir(str(disallowed))

This approach keeps everything within tmp_path while still testing the rejection logic by mocking the allowed-dirs sources.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_resolve_cli_project_dir_rejects_outside_allowed_prefixes(tmp_path: Path, monkeypatch):
monkeypatch.chdir(tmp_path)
disallowed = Path.home().parent / "paperbot-studio-chat-disallowed"
disallowed.mkdir(parents=True, exist_ok=True)
with pytest.raises(ValueError, match="not allowed"):
_resolve_cli_project_dir(str(disallowed))
def test_resolve_cli_project_dir_rejects_outside_allowed_prefixes(tmp_path: Path, monkeypatch):
# Create two isolated directories - one as cwd, one as "disallowed"
cwd_dir = tmp_path / "cwd"
cwd_dir.mkdir()
disallowed = tmp_path / "disallowed"
disallowed.mkdir()
monkeypatch.chdir(cwd_dir)
# Clear any env overrides and runtime config to ensure only cwd is allowed
monkeypatch.setenv("PAPERBOT_RUNBOOK_ALLOW_DIR_PREFIXES", "")
monkeypatch.setattr(
"paperbot.api.routes.studio_chat._load_runtime_allowed_dirs",
lambda: [],
)
with pytest.raises(ValueError, match="not allowed"):
_resolve_cli_project_dir(str(disallowed))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/test_studio_chat_path_validation.py` around lines 22 - 28, The
test currently creates a persistent directory outside the test sandbox; instead
create the disallowed directory under the provided tmp_path and then mock the
source of allowed prefixes so the path is considered "outside" allowed prefixes;
e.g., create disallowed = tmp_path / "paperbot-studio-chat-disallowed" and then
monkeypatch the mechanism used to compute allowed prefixes (or monkeypatch
Path.home()/Path.home().parent) so that disallowed is not within allowed
prefixes, then call _resolve_cli_project_dir(str(disallowed)) expecting
ValueError; this keeps files inside tmp_path and avoids permission/artifact
issues.

Comment on lines +101 to +106
const normalizedTitle = title.trim().toLowerCase()
const duplicate = papers.find(p => p.title.trim().toLowerCase() === normalizedTitle)
if (duplicate) {
setError("A paper with this title already exists in the studio.")
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add regression tests for the new title-based dedup flows.

Line 101 and Line 117 introduce new behavior branches (manual duplicate rejection, import-skip handling, and singular/plural error messaging), but there are no corresponding test updates in the provided changes. Please add tests for these paths before merge.

As per coding guidelines, {src,web/src}/**/*.{py,ts,tsx}: If behavior changes, add or update tests.

Also applies to: 117-150

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/studio/NewPaperModal.tsx` around lines 101 - 106, The PR
added title-based dedup logic in NewPaperModal.tsx (look for the NewPaperModal
component and the normalizedTitle/duplicate check that calls setError) and
additional branches for manual duplicate rejection, import-skip behavior, and
singular vs plural error messages; add regression tests that render
NewPaperModal and simulate: submitting a title that differs only by
case/whitespace to assert the duplicate branch sets the correct error message,
the manual-reject path (simulate the user choosing "reject duplicate") to assert
the modal closes/sets state as expected, the import-skip flow to assert it
returns the expected skip result instead of error, and both singular/plural
message variations (1 vs >1 existing duplicates) to assert correct wording;
place tests alongside other web/src component tests (tsx test files), use the
component's public handlers (submit button, props) to drive the scenarios, and
assert on DOM error text and any emitted callbacks.

Comment on lines +2 to +13
const listRes = await fetch(
`/api/runbook/files?project_dir=${encodeURIComponent(projectDir)}&recursive=true`
);
if (!listRes.ok) {
throw new Error(`Failed to list project files (${listRes.status})`);
}

const payload = (await listRes.json()) as { files?: unknown };
const files = Array.isArray(payload.files)
? payload.files.filter((v): v is string => typeof v === "string" && v.length > 0)
: [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent silent partial deletion when file listing is truncated.

Line 2-Line 12 ignores the truncated response flag from /api/runbook/files. If the project has more files than the API cap, this function deletes only a subset and still reports success.

Suggested fix
-  const listRes = await fetch(
-    `/api/runbook/files?project_dir=${encodeURIComponent(projectDir)}&recursive=true`
-  );
+  const listRes = await fetch(
+    `/api/runbook/files?project_dir=${encodeURIComponent(projectDir)}&recursive=true&max_files=20000`
+  );
@@
-  const payload = (await listRes.json()) as { files?: unknown };
+  const payload = (await listRes.json()) as {
+    files?: unknown;
+    truncated?: boolean;
+    max_files?: number;
+  };
+  if (payload.truncated) {
+    throw new Error(
+      `File list truncated at ${payload.max_files ?? "unknown"} entries; aborting partial delete`
+    );
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const listRes = await fetch(
`/api/runbook/files?project_dir=${encodeURIComponent(projectDir)}&recursive=true`
);
if (!listRes.ok) {
throw new Error(`Failed to list project files (${listRes.status})`);
}
const payload = (await listRes.json()) as { files?: unknown };
const files = Array.isArray(payload.files)
? payload.files.filter((v): v is string => typeof v === "string" && v.length > 0)
: [];
const listRes = await fetch(
`/api/runbook/files?project_dir=${encodeURIComponent(projectDir)}&recursive=true&max_files=20000`
);
if (!listRes.ok) {
throw new Error(`Failed to list project files (${listRes.status})`);
}
const payload = (await listRes.json()) as {
files?: unknown;
truncated?: boolean;
max_files?: number;
};
if (payload.truncated) {
throw new Error(
`File list truncated at ${payload.max_files ?? "unknown"} entries; aborting partial delete`
);
}
const files = Array.isArray(payload.files)
? payload.files.filter((v): v is string => typeof v === "string" && v.length > 0)
: [];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/lib/runbook/deleteProjectFiles.ts` around lines 2 - 13, The file
listing handler in deleteProjectFiles.ts currently ignores the response's
truncation flag and may proceed to delete only a subset; update the code that
parses listRes.json() (payload) to check for a boolean payload.truncated (or
equivalent) and, if true, abort and surface an error instead of continuing;
ensure the same function (where listRes, payload, and files are defined)
throws/reports a clear error when truncated is true so deletions are not
performed on a partial file list.

@jerry609
jerry609 merged commit 82127d1 into master Mar 4, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants