Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/workflows/pm-cli-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# pm CLI 多平台构建 + Release 发布
#
# 触发方式:
# 1. 推送 tag pm-v*(如 pm-v0.1.0)→ 构建四平台二进制并发布到 GitHub Release
# 2. workflow_dispatch 手动触发 → 只构建并上传 workflow artifact(用于验证)
#
# 产物命名:pm-darwin-arm64 / pm-darwin-x86_64 / pm-linux-x86_64 / pm-windows-x86_64.exe
# 注意:macOS 产物未做 Apple 公证,用户首次运行需
# xattr -d com.apple.quarantine pm (或在「系统设置 → 隐私与安全性」放行)
name: pm-cli-release

on:
push:
tags:
- "pm-v*"
workflow_dispatch:

permissions:
contents: write

jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: macos-14 # Apple Silicon
target: darwin-arm64
- os: macos-13 # Intel
target: darwin-x86_64
- os: ubuntu-22.04
target: linux-x86_64
- os: windows-latest
target: windows-x86_64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

# CLI 只依赖 typer + httpx,无需安装整个 papermind 包(避免拖入 fastapi/pgvector 等)
- name: Install build dependencies
run: pip install "typer>=0.12" "httpx>=0.28.1" "pyinstaller>=6.0"

- name: Build binary
run: >
pyinstaller --onefile --clean --noconfirm --name pm
--paths .
--hidden-import "apps.cli.main"
--hidden-import "apps.cli.client"
--hidden-import "apps.cli.config"
--collect-data certifi
apps/cli/__main__.py

- name: Smoke test (--version, unix)
if: runner.os != 'Windows'
run: dist/pm --version

- name: Smoke test (--version, windows)
if: runner.os == 'Windows'
shell: pwsh
run: dist\pm.exe --version

- name: Rename artifact (unix)
if: runner.os != 'Windows'
run: mv dist/pm "dist/pm-${{ matrix.target }}"

- name: Rename artifact (windows)
if: runner.os == 'Windows'
shell: pwsh
run: Move-Item dist\pm.exe "dist/pm-${{ matrix.target }}.exe"

- uses: actions/upload-artifact@v4
with:
name: pm-${{ matrix.target }}
path: dist/pm-*
if-no-files-found: error

- name: Attach to GitHub Release
if: startsWith(github.ref, 'refs/tags/pm-v')
uses: softprops/action-gh-release@v2
with:
files: dist/pm-*
generate_release_notes: true
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,14 @@ PaperMind 是一个面向科研工作者的 AI 增强平台,帮你从「搜索
- 🎫 **JWT Token** —— 7 天有效期,自动续期
- 🛡️ **全站保护** —— 所有 API 都需要认证

### 🔑 API 令牌与 pm CLI

让 pm CLI、Claude Code / ZCode 等 AI harness 安全访问你的 PaperMind:

- 📱 **设备码授权登录** —— `pm login` 打开浏览器确认设备码,自动签发长期令牌(类似 `gh auth login`)
- 🛡️ **细粒度权限** —— read / write scope 按 HTTP 方法强制,网页端随时创建/吊销
- 🩺 **pm doctor** —— 一条命令体检连通性与认证状态

### ⚙️ LLM 模型管理

灵活控制成本,按场景分配模型:
Expand All @@ -193,8 +201,67 @@ PaperMind 是一个面向科研工作者的 AI 增强平台,帮你从「搜索

---

## 🖥️ pm CLI 与 API 令牌

### 安装(你的电脑上,无需 Python)

```bash
# macOS / Linux:一键安装(自动识别平台,从 GitHub Releases 下载)
curl -fsSL https://raw.githubusercontent.com/Color2333/PaperMind/main/scripts/install-pm.sh | bash

# Windows(PowerShell):
irm https://raw.githubusercontent.com/Color2333/PaperMind/main/scripts/install-pm.ps1 | iex
```

或到 [Releases](https://github.com/Color2333/PaperMind/releases) 手动下载对应平台二进制
(`pm-darwin-arm64` / `pm-darwin-x86_64` / `pm-linux-x86_64` / `pm-windows-x86_64.exe`),
放到 PATH 目录并 `chmod +x`。

> macOS 首次运行如被 Gatekeeper 拦截:`xattr -d com.apple.quarantine pm`

开发者也可以从源码安装:`pipx install /path/to/PaperMind`,或本地构建单文件二进制
`bash scripts/build-pm-cli.sh`(输出 `dist/pm`)。

### 登录

```bash
# 设备码授权(推荐):浏览器确认设备码后自动完成
pm login --server https://pm.your-domain.com

# 兜底:网页「设置 → API 令牌」创建后粘贴(SSH 等无浏览器环境)
pm login --token pmt_xxx

pm whoami # 查看身份与权限
pm doctor # 连通性体检
pm logout # 吊销令牌并清除本地配置
```

配置存于 `~/.config/papermind/config.toml`(权限 0600),也可用环境变量 `PAPERMIND_SERVER_URL` / `PAPERMIND_TOKEN` 覆盖。

### 权限模型

| 凭证 | 来源 | 权限 |
|------|------|------|
| JWT(7 天) | 网页密码登录 | 全部,含令牌管理 |
| API 令牌 `pmt_` | 网页创建 / 设备码签发 | scope 强制:GET → `read`,变更操作 → `write`;仅能吊销自己 |

数据库只存令牌 SHA-256 哈希,明文仅创建时返回一次;令牌管理接口(创建/列表/吊销他人)仅限网页会话。

### 接入 Claude Code / ZCode(MCP)

服务端 `/mcp` 同时接受静态 `MCP_AUTH_TOKEN` 与数据库 API 令牌:

```bash
# 用 pm login 签发的令牌接入 Claude Code
claude mcp add --transport http papermind https://pm.your-domain.com/mcp \
--header "Authorization: Bearer pmt_xxx"
```

---

## 🏗️ 架构总览


```
┌─────────────────────────────────────────────────────────────┐
│ Frontend (React 18) │
Expand Down
41 changes: 39 additions & 2 deletions apps/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
from starlette.middleware.gzip import GZipMiddleware

from apps.api.middleware.demo_mode import DemoModeMiddleware
from packages.auth import decode_access_token
from apps.api.token_auth import lookup_api_token, required_scope_for_method
from packages.auth import API_TOKEN_PREFIX, decode_access_token
from packages.config import get_settings
from packages.domain.exceptions import AppError
from packages.logging_setup import setup_logging
Expand Down Expand Up @@ -51,13 +52,21 @@ async def dispatch(self, request: Request, call_next):


class AuthMiddleware(BaseHTTPMiddleware):
"""认证中间件 - 保护所有 API(白名单除外)"""
"""认证中间件 - 保护所有 API(白名单除外)

支持两种凭证:
- JWT(网页会话,/auth/login 签发,7 天有效):完整权限
- API 令牌(pmt_ 前缀,DB 校验,read/write scope):按方法强制 scope
GET/HEAD → read,其余方法 → write
"""

# 白名单路径(无需认证)
WHITELIST = {
"/health",
"/auth/login",
"/auth/status",
"/auth/device/start",
"/auth/device/poll",
"/mcp",
}

Expand Down Expand Up @@ -88,20 +97,48 @@ async def dispatch(self, request: Request, call_next):
token = request.query_params.get("token")

if not token:
api_logger.warning("[%s] 401 缺少凭证 %s", request.url.path, request.client)
return JSONResponse(
status_code=401,
content={"detail": "Not authenticated"},
)

# API 令牌:pmt_ 前缀,查 DB 校验 + scope 强制
if token.startswith(API_TOKEN_PREFIX):
info = lookup_api_token(token)
if info is None:
api_logger.warning("401 无效 API 令牌 %s", request.url.path)
return JSONResponse(
status_code=401,
content={"detail": "Invalid or expired token"},
)
required = required_scope_for_method(request.method)
if required not in info.scopes:
api_logger.warning(
"403 API 令牌 %s 缺少 %s scope %s", info.prefix, required, request.url.path
)
return JSONResponse(
status_code=403,
content={"detail": f"令牌缺少 {required} 权限"},
)
request.state.auth_method = "api_token"
request.state.auth_scopes = info.scopes
request.state.token_id = info.token_id
request.state.token_name = info.name
request.state.token_prefix = info.prefix
return await call_next(request)

payload = decode_access_token(token)
if not payload:
api_logger.warning("401 无效 JWT %s", request.url.path)
return JSONResponse(
status_code=401,
content={"detail": "Invalid or expired token"},
)

# 将用户信息存入 request.state
request.state.user = payload
request.state.auth_method = "jwt"
return await call_next(request)


Expand Down
67 changes: 50 additions & 17 deletions apps/api/mcp.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,69 @@
"""PaperMind MCP server —— 挂载到现有 FastAPI,供 hermes agent 接入。
"""PaperMind MCP server —— 挂载到现有 FastAPI,供 hermes agent / pm CLI 接入。

Streamable HTTP transport(MCP 2025-06-18 规范)。鉴权两级:
1. DB API 令牌(pmt_ 前缀,pm login / 网页签发,带 scope)
2. 静态 MCP_AUTH_TOKEN(hermes 常驻应急用)
两者都未配置时不启用鉴权(仅开发用)。

Streamable HTTP transport(MCP 2025-06-18 规范),Bearer 静态 token 鉴权。
暴露论文查询 / 每日简报 / 论文推荐 / 触发处理任务四类工具,复用现有 service 单例。

@author Color2333
"""

from __future__ import annotations

import hmac
import os

from fastmcp import FastMCP
from fastmcp.server.auth.auth import AccessToken, TokenVerifier

# 独立静态 token(hermes 常驻用,免续期)。未配置时 MCP 端点不启用鉴权(仅开发用)。
# 静态令牌(hermes 常驻用,免续期)。未配置时仅依赖 DB 令牌;都没有则不鉴权(仅开发用)。
_MCP_TOKEN = os.environ.get("MCP_AUTH_TOKEN", "")

if _MCP_TOKEN:
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier

_verifier = StaticTokenVerifier(
tokens={
_MCP_TOKEN: {
"client_id": "hermes-agent",
"sub": "hermes",
"scopes": ["read", "write"],
}
},
required_scopes=["read"],
)

class _DbFallbackVerifier(TokenVerifier):
"""先查 DB API 令牌(pm login / 网页签发,哈希存储 + scope),失败回落静态令牌。"""

def __init__(self, static_token: str, **kwargs):
super().__init__(**kwargs)
self._static_token = static_token

async def verify_token(self, token: str) -> AccessToken | None:
from starlette.concurrency import run_in_threadpool

from apps.api.token_auth import lookup_api_token

info = await run_in_threadpool(lookup_api_token, token)
if info is not None:
access = AccessToken(
token=token,
client_id=info.name,
scopes=info.scopes,
claims={"sub": info.name, "auth_method": "api_token", "scopes": info.scopes},
)
elif self._static_token and hmac.compare_digest(token, self._static_token):
access = AccessToken(
token=token,
client_id="hermes-agent",
scopes=["read", "write"],
claims={"sub": "hermes", "auth_method": "static", "scopes": ["read", "write"]},
)
else:
return None
# required_scopes 子集校验(与 StaticTokenVerifier 行为一致)
if self.required_scopes and not set(self.required_scopes) <= set(access.scopes or []):
return None
return access


from packages.config import get_settings # noqa: E402

if _MCP_TOKEN or get_settings().auth_password:
_verifier = _DbFallbackVerifier(static_token=_MCP_TOKEN, required_scopes=["read"])
mcp = FastMCP("papermind-mcp", auth=_verifier)
else:
# 未配 token:开发模式不鉴权(生产必须配 MCP_AUTH_TOKEN)
# 未配任何凭证:开发模式不鉴权(生产必须配 MCP_AUTH_TOKEN 或使用 DB 令牌)
mcp = FastMCP("papermind-mcp")


Expand Down
Loading
Loading