openscience 科研 Agent 工作台
OpenScience 不是单一聊天壳,而是把本地 server、SolidJS workspace、模型路由、科研数据库连接器和插件系统接成一个科研循环:读论文、写代码、跑实验、沉淀结果。
它解决什么,凭什么值得拆
这个仓库值得拆,是因为它把“科研 Agent”从口号落到本地应用骨架:CLI 入口在 backend/cli/src/index.ts:55 建出完整命令树,默认 web 命令在 backend/cli/src/cli/cmd/web.ts:60 接管裸 `openscience`,server 再服务浏览器工作台。它的关键不是模型调用,而是把模型、工具、权限、配置和 UI 都放在同一个可审计闭环里。
源码里最有价值的设计是“本地优先但可扩展”:Host/Origin guard 在 backend/cli/src/server/host-guard.ts:19 限定 loopback host,又在 backend/cli/src/server/host-guard.ts:54 单独判断 origin;配置系统从远程 well-known、global、project、`.openscience` 和 synced config 分层合并,见 backend/cli/src/config/config.ts:75 到 backend/cli/src/config/config.ts:207。这让它既像桌面软件,又保留 agent 工具链的可组合性。 入口 middleware 还会设置运行时标记 backend/cli/src/index.ts:83。 credential 注入发生在命令执行前 backend/cli/src/index.ts:101。 compute keys 也在同一层应用 backend/cli/src/index.ts:105。 命令注册覆盖 serve/web/session/project backend/cli/src/index.ts:121。 错误格式化集中在 fatal catch backend/cli/src/index.ts:189。
▸核心数据
深度阅读 → 入口展开。
本地科研工作台的三层结构
从源码看,OpenScience 的边界先由 CLI 入口建立,再由 web 命令启动 server 和浏览器,最后由 server guard 把访问面约束在本机。backend/cli/src/index.ts:55 的 yargs 命令树、backend/cli/src/cli/cmd/web.ts:147 的 server listen、backend/cli/src/server/host-guard.ts:46 的 host 检查形成第一条主链。
▸系统拓扑
▸模块清单
模块不是按“页面”拆,而是按运行时责任拆。backend/cli/src/index.ts:112 到 backend/cli/src/index.ts:143 把 CLI 子命令注册成产品入口;backend/cli/src/agent/agent.ts:77 建立内置 agent;backend/cli/src/provider/provider.ts:57 装配 provider SDK;backend/cli/src/science/connectors/index.ts:30 注册科学数据源。
backend/cli/src/index.ts:55 到 backend/cli/src/index.ts:159 是命令入口;web 命令负责启动本地工作台。backend/cli/src/config/config.ts:75 分层加载 config,backend/cli/src/index.ts:98 注入 settings credentials。backend/cli/src/agent/agent.ts:77 定义 research/biology/physics/ml/plan 等模式,并合并权限规则。backend/cli/src/provider/provider.ts:57 维护 SDK provider 表,backend/cli/src/provider/provider.ts:143 处理 managed wallet 路由。backend/cli/src/science/connectors/types.ts:76 统一 search/fetch 契约,backend/cli/src/science/connectors/index.ts:30 批量注册。tooling/plugin/src/index.ts:148 暴露 Hooks,插件可改 chat 参数、工具调用和权限询问。▸关键数据结构
76/** The uniform contract every scientific data source implements. */ 77export interface Connector { 78 /** Unique, stable, lowercase id used to route (e.g. "uniprot", "rcsb-pdb"). */ 79 id: string 80 /** Display name (e.g. "UniProt", "RCSB PDB"). */ 81 name: string 82 /** Primary domain for grouping in the catalog. */ 83 domain: ConnectorDomain 84 /** One-line description shown in `science_list_dbs`. */ 85 description: string 86 /** Homepage / docs URL. */ 87 homepage?: string 88 /** Search the source; returns normalized hits. */ 89 search(query: string, opts?: SearchOptions): Promise<ConnectorHit[]> 90 /** Fetch a single record by id. Return shape is source-specific. */ 91 fetch(id: string, opts?: FetchOptions): Promise<unknown> 92}
Agent、模型与配置如何装配
▸Agent registry 把科研角色变成权限化模式
`Agent.Info` 先定义统一 schema,再在实例状态里合并默认权限、用户权限和内置角色。比如 research 允许 question 和 plan_enter,证据在 backend/cli/src/agent/agent.ts:77、backend/cli/src/agent/agent.ts:85、backend/cli/src/agent/agent.ts:88;plan 模式则显式 deny edit,只允许写计划文件,见 backend/cli/src/agent/agent.ts:164、backend/cli/src/agent/agent.ts:176、backend/cli/src/agent/agent.ts:179。这说明它不是把 prompt 当角色,而是把角色和权限边界一起声明。 biology 继承相同 permission merge 模式 backend/cli/src/agent/agent.ts:97。 ml agent 把训练和评估作为描述核心 backend/cli/src/agent/agent.ts:131。 task subagent 禁止 todo 工具避免污染主上下文 backend/cli/src/agent/agent.ts:188。 explore subagent 把读搜索类工具打开 backend/cli/src/agent/agent.ts:203。 literature-review subagent 只开放检索和读取 backend/cli/src/agent/agent.ts:230。
77 const result: Record<string, Info> = { 78 // --- Research modes (top) --- 79 research: { 80 name: "research", 81 description: 82 "Scientific research agent — literature review, data analysis, GPU compute, and synthesis across 241 skills.", 83 options: {}, 84 color: "#06b6d4", 85 permission: PermissionNext.merge( 86 defaults, 87 PermissionNext.fromConfig({ 88 question: "allow", 89 plan_enter: "allow", 90 }), 91 user, 92 ), 93 mode: "primary", 94 native: true, 95 },
▸Provider routing 同时处理 BYOK、managed wallet 和本地模型
Provider 层先列出 SDK 工厂,见 backend/cli/src/provider/provider.ts:57 到 backend/cli/src/provider/provider.ts:80;随后用 backend/cli/src/provider/provider.ts:82 识别 Atlas key,用 backend/cli/src/provider/provider.ts:153 判断本地 baseURL,用 backend/cli/src/provider/provider.ts:176 防止 BYOK key 被误送到 Atlas proxy。这个细节比“支持很多模型”更重要,因为科研工作台最怕凭证和账单路径混淆。 OpenAI loader 可选择 responses API backend/cli/src/provider/provider.ts:241。 Copilot loader 对 GPT-5 以后模型走 responses backend/cli/src/provider/provider.ts:251。 本地 URL 判定保留 localhost 模型 backend/cli/src/provider/provider.ts:157。 synsci 无 key 时只保留免费模型 backend/cli/src/provider/provider.ts:229。 Atlas session key 只在 managed proxy 路径注入 backend/cli/src/provider/provider.ts:106。
82 function isAtlasApiKey(key: unknown): key is string { 83 return typeof key === "string" && key.startsWith("thk_") 84 } 85 86 function isAtlasProxyBaseURL(baseURL: unknown): baseURL is string { 87 return typeof baseURL === "string" && baseURL.includes("/api/llm/proxy/") 88 } 89 90 // Explicit apiKey for a provider routed through the Atlas managed proxy: force 91 // the session thk_ token. These SDKs read `<PROVIDER>_API_KEY` straight from 92 // env, so a shell `export OPENAI_API_KEY=sk-...` would otherwise shadow the 93 // managed token and 401 the proxy ("thk_* token not found"). Returns {} unless 94 // both managed spend is on AND the baseURL is the Atlas proxy — genuine BYOK 95 // (no proxy URL) is untouched and hits the provider directly. 96 async function managedProxyKey(providerID: string, baseURL: unknown): Promise<{ apiKey?: string }> { 97 const managed = 98 isAtlasProxyBaseURL(baseURL) && (await Config.get().catch(() => undefined))?.billing?.llm === "managed" 99 if (!managed) return {} 100 // Managed wallet routes through OpenRouter ONLY: never attach the wallet's 101 // thk_ token to a first-party proxy (anthropic / openai / google). Those 102 // providers are also dropped from availability (see isProviderAllowed), so 103 // this is belt-and-suspenders — a managed token can only ever reach the 104 // sanctioned OpenRouter (or hosted synsci) route. 105 if (!managedProviderAllowed(providerID)) return {} 106 const session = await OpenScience.getSession().catch(() => null) 107 return session?.api_key ? { apiKey: session.api_key } : {} 108 }
从 openscience 命令到浏览器会话
启动流程不是简单 `open browser`。backend/cli/src/cli/cmd/web.ts:79 先解析网络选项,backend/cli/src/cli/cmd/web.ts:87 处理首次 onboarding,backend/cli/src/cli/cmd/web.ts:100 判断 Atlas 登录,backend/cli/src/cli/cmd/web.ts:121 给 managed config sync 设置 6 秒预算,最后 backend/cli/src/cli/cmd/web.ts:147 绑定 server。这个顺序避免浏览器首屏读到过期模型配置。 macOS Full Disk Access 探针在启动前运行 backend/cli/src/cli/cmd/web.ts:17。 项目参数会先切换 cwd backend/cli/src/cli/cmd/web.ts:71。 未配置模型时只提示不阻断 backend/cli/src/cli/cmd/web.ts:105。 超时后继续后台同步 backend/cli/src/cli/cmd/web.ts:127。 退出时 server.stop 有 watchdog backend/cli/src/cli/cmd/web.ts:173。
▸首启路径:配置先于 server 绑定
▸请求防护:Host 与 Origin 双闸
测试与验证边界
这个仓库的质量边界更多体现在运行时防护和配置容错里。比如 remote well-known config 失败不能让 CLI 崩掉,源码在 backend/cli/src/config/config.ts:85、backend/cli/src/config/config.ts:100;server guard 则把 DNS rebinding 和 cross-origin websocket 放在注释里解释,见 backend/cli/src/server/host-guard.ts:5、backend/cli/src/server/host-guard.ts:11。
▸测试覆盖
仓库包含前端序列化和布局滚动测试,但这次源码分析更关注可直接验证的防护代码。下面的 guard 函数很短,却承担了本地 server 的暴露面控制:backend/cli/src/server/host-guard.ts:46 解析 host,backend/cli/src/server/host-guard.ts:54 校验 origin,backend/cli/src/server/host-guard.ts:70 兜底 Sec-Fetch-Site。
46export function isAllowedHost(hostHeader: string | undefined): boolean { 47 if (!hostHeader) return false 48 const host = hostHeader.startsWith("[") 49 ? hostHeader.slice(0, hostHeader.indexOf("]") + 1) // keep "[::1]" bracket form 50 : hostHeader.split(":")[0] 51 return ALLOWED_HOSTS.has(host) 52} 53 54export function isAllowedOrigin(origin: string, extraWhitelist: string[] = []): boolean { 55 if (origin.startsWith("http://localhost:") || origin === "http://localhost") return true 56 if (origin.startsWith("http://127.0.0.1:") || origin === "http://127.0.0.1") return true 57 if (origin === "tauri://localhost" || origin === "http://tauri.localhost") return true 58 // Trusted hosted-UI subdomains (default syntheticsciences.ai, extended via 59 // OPENSCIENCE_CORS_DOMAINS). See matchesAllowedSubdomain. 60 if (matchesAllowedSubdomain(origin)) return true 61 return extraWhitelist.includes(origin) 62} 63 64// Decide whether a NETWORK request is cross-origin and must be rejected. An 65// Origin header (always present on cross-origin fetch and WebSocket upgrades) 66// is checked against the allow-list; when it is absent (same-origin requests 67// and same-/cross-site GET navigations omit it) we fall back to Sec-Fetch-Site 68// and reject only explicit cross-site contexts. Non-browser clients send 69// neither header and are allowed (the loopback bind already gates them). 70export function isCrossOrigin( 71 origin: string | undefined, 72 secFetchSite: string | undefined, 73 extraWhitelist: string[] = [], 74): boolean { 75 if (origin !== undefined) return !isAllowedOrigin(origin, extraWhitelist) 76 return secFetchSite === "cross-site" 77}
▸Provider 路由的 BYOK 保护
这一段补足一个容易被 README 忽略的安全细节:当用户自己的 key 和 Atlas managed proxy 同时存在时,backend/cli/src/provider/provider.ts:176 会计算 effective key,backend/cli/src/provider/provider.ts:181 排除 managed key,backend/cli/src/provider/provider.ts:184 发现 BYOK 误走 Atlas proxy 就改回 public endpoint。这个逻辑不是功能炫技,而是在保护科研工作台里最敏感的凭证路径。
176 function pinByokToPublicEndpoint(provider: Info, options: Record<string, any>, publicURL: string) { 177 const effectiveKey = 178 (typeof options["apiKey"] === "string" ? (options["apiKey"] as string) : undefined) ?? 179 provider.key ?? 180 provider.env.map((key) => Env.get(key)).find((value): value is string => !!value) 181 // Managed (thk_*) keys must keep their Atlas proxy routing. 182 if (isAtlasApiKey(effectiveKey)) return 183 if (!isByokKey(effectiveKey)) return 184 if (isAtlasProxyBaseURL(options["baseURL"])) { 185 log.warn("refusing to route BYOK key through Atlas proxy — pinning to public endpoint", { 186 provider: provider.id, 187 }) 188 options["baseURL"] = publicURL 189 } 190 }
▸配置目录也会安装插件依赖
另一个必须看源码才会注意到的点在配置加载:backend/cli/src/config/config.ts:178 检查配置目录是否已有 node_modules,backend/cli/src/config/config.ts:179 触发 installDependencies,backend/cli/src/config/config.ts:272 会安装 `@synsci/plugin`,backend/cli/src/config/config.ts:281 还会执行 `bun install`。所以扩展性和供应链信任是绑在一起的。
261 export async function installDependencies(dir: string) { 262 const pkg = path.join(dir, "package.json") 263 264 if (!(await Bun.file(pkg).exists())) { 265 await Bun.write(pkg, "{}") 266 } 267 268 const gitignore = path.join(dir, ".gitignore") 269 const hasGitIgnore = await Bun.file(gitignore).exists() 270 if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n")) 271 272 await BunProc.run( 273 ["add", "@synsci/plugin@" + (Installation.isLocal() ? "latest" : Installation.VERSION), "--exact"], 274 { 275 cwd: dir, 276 }, 277 ).catch(() => {}) 278 279 // Install any additional dependencies defined in the package.json 280 // This allows local plugins and custom tools to use external packages 281 await BunProc.run(["install"], { cwd: dir }).catch(() => {})
▸已知边界 / 失败模式
- YouTube 式远端服务不可控时,OpenScience 的 remote well-known config 已经用
backend/cli/src/config/config.ts:88到backend/cli/src/config/config.ts:105做了降级;但每个具体 science connector 的失败策略还需要逐个审。 - Agent 权限默认很宽:defaults 里
backend/cli/src/agent/agent.ts:55设置 `* allow`,再叠加用户规则;部署给高风险科研环境时应把项目策略收紧。 - README 明确说 agent 不是 sandbox;源码里 Host/Origin guard 保护的是本地 server 暴露面,不等于 shell/code execution 隔离。
值得借鉴的工程亮点
backend/cli/src/config/config.ts:78 到 backend/cli/src/config/config.ts:131 按 remote、global、custom、project、inline 分层,且远程失败只 warning。backend/cli/src/provider/provider.ts:176 会把 BYOK key 从 Atlas proxy 拉回 public endpoint,保护用户凭证。backend/cli/src/science/connectors/types.ts:76 用 search/fetch 契约统一 UniProt/PDB/arXiv 等连接器。tooling/plugin/src/index.ts:171、tooling/plugin/src/index.ts:184、tooling/plugin/src/index.ts:218 分别允许改 chat 参数、工具参数和 compaction。技术亮点之外,别忽略这些
源码显示 OpenScience 有清楚的本地边界和扩展点,但它也把很多能力集中在一个本地 agent runtime 内。backend/cli/src/index.ts:98 会把凭证注入进程,backend/cli/src/config/config.ts:272 会为配置目录安装 plugin 依赖,backend/cli/src/agent/agent.ts:55 默认权限偏开放;这些都适合个人工作台,但企业环境需要额外策略。
backend/cli/src/server/host-guard.ts:19 保护网络入口,不保护子进程副作用。backend/cli/src/config/config.ts:261 到 backend/cli/src/config/config.ts:281 会创建 package 并 bun install,本地插件目录需要可信来源。backend/cli/src/provider/provider.ts:90 到 backend/cli/src/provider/provider.ts:125 的 managed proxy 逻辑很细,测试缺口会直接影响账单和凭证路径。backend/cli/src/science/connectors/index.ts:30 注册多个数据库,但每个外部 API 的限流和失败语义仍需单独观察。