feat(server,web): serve Workspace HTML previews from a separate origin (#46)

Serve "open in new tab" HTML previews from a separate origin (the loopback counterpart, or PENGUIN_PREVIEW_ORIGIN) with a signed, host-bound token, so localStorage/cookies/third-party embeds work while Agent-generated pages stay off the app origin. The app is canonicalized onto localhost and the preview host (127.0.0.1) serves only /preview/* — its /api answers 401 and app routes 302 to the canonical host — so the preview origin can neither set nor honor a session cookie.
This commit is contained in:
Yaowei Zheng authored and GitHub committed 2026-07-23 23:11:04 +08:00
1 parent cc1be054de
commit b18ae8f5f1
22 files changed
+842 -14

No files matched your search

@@ -16,8 +16,11 @@ The CLI and the server automatically load a `.env` file from the working directo
| `HOST` | Web service listen address | `127.0.0.1` |
| `PENGUIN_WEB_DB` | Server SQLite database path | `<root>/web.db` |
| `PENGUIN_WEB_DIST` | Front-end static assets directory | the npm server package falls back to its bundled web-dist |
| `PENGUIN_PREVIEW_ORIGIN` | Origin that serves Workspace HTML previews, e.g. `https://preview.example.com` | unset — the loopback counterpart is derived per request |
| `PENGUIN_LANG` | CLI language (`en` / `zh`), set via `penguin config lang` | `en` |
`PENGUIN_PREVIEW_ORIGIN` must differ from the app's origin by **hostname**, not just port: cookies ignore ports, so a second port would still share the session cookie. Leave it unset for local use — the app is canonicalized onto `localhost` and previews are served from `127.0.0.1`, which needs no configuration and no DNS. Set it when the app is reached over a LAN address or a real domain; otherwise previews there fall back to a same-origin sandbox where `localStorage`, cookies and third-party embeds do not work. When you do set it on a real domain, keep the session cookie host-only (no `Domain=`), or a sibling subdomain shares it. An unparseable value is a startup error rather than a silent fallback.
### Provider credential variables
When a model entry has no inline `api_key`, the AgentHub gateway falls back to the provider's environment variable; the `*_BASE_URL` variants override the base URL the same way:
@@ -16,8 +16,11 @@ CLI 与服务端启动时会自动加载工作目录下的 `.env` 文件。
| `HOST` | Web 服务监听地址 | `127.0.0.1` |
| `PENGUIN_WEB_DB` | 服务端 SQLite 数据库路径 | `<root>/web.db` |
| `PENGUIN_WEB_DIST` | 前端静态资源目录 | npm 安装的服务端包回退到内置 web-dist |
| `PENGUIN_PREVIEW_ORIGIN` | 提供 Workspace HTML 预览的独立源,如 `https://preview.example.com` | 未设置,按请求推导回环对应名 |
| `PENGUIN_LANG` | CLI 语言(`en` / `zh`),用 `penguin config lang` 设置 | `en` |
`PENGUIN_PREVIEW_ORIGIN` 必须与应用源在**主机名**上不同,只换端口不行:Cookie 不区分端口,换端口仍然共用会话 Cookie。本地使用不必配置——App 固定在规范主机 `localhost`,预览用 `127.0.0.1`,既不需要配置也不需要 DNS。经 LAN 地址或真实域名访问时才需要设置,否则那里的预览会回退到同源沙箱,`localStorage`、Cookie 与第三方 embed 都不可用。在真实域名上设置时,会话 Cookie 必须保持 host-only(不带 `Domain=`),否则同注册域下的兄弟子域会共享它。取值无法解析时启动即报错,不会静默回退。
### Provider 凭证环境变量
当模型条目未内联 `api_key` 时,AgentHub 网关按 Provider 回退读取对应环境变量;`*_BASE_URL` 变体同理覆盖 Base URL:
+19 -1
View File
@@ -150,6 +150,7 @@ The paths below omit the `/api/sessions/:sessionId` prefix. For the storage mode
| POST | /compact | Trigger context compaction: 202; 409 `nothing_to_compact` when there is nothing to compact |
| GET | /files?path= | Browse the Workspace directory |
| GET | /files/content?path=&download=&preview= | Read a Workspace file (`download=1` serves it as an attachment, `preview=1` renders it in a sandbox — see below) |
| GET | /files/preview-redirect?path= | "Open in a new tab" for html: mints a signed token and 302s to the separate preview origin |
| POST | /files/stat | Batch existence check: `{paths}` |
| PUT | /files/content?path= | Upload a file: `{dataBase64}`, capped at 14MB |
| GET | /traces | List this Session's Trace files |
@@ -167,7 +168,24 @@ Workspace files may be Agent-generated, so `GET /files/content` treats them as u
| `preview=1` | the real type (`text/html`, `image/svg+xml`, …) | `inline` | `sandbox allow-scripts allow-popups allow-modals allow-forms`, sent only for `.html` / `.htm` / `.svg` |
| `download=1` | the real type | `attachment` | — |
The filename always rides along as `filename*=UTF-8''` with percent-encoding. `preview=1` is what backs "open in a new tab": the document keeps its real type and does render and run, but the sandbox deliberately omits `allow-same-origin`, so it lands in an opaque origin and can reach neither this origin's cookies nor the API — while the request itself still authenticates, because a top-level GET sends the SameSite=Lax session cookie.
The filename always rides along as `filename*=UTF-8''` with percent-encoding. `preview=1` renders inside the Files panel's sandboxed iframe, and is also the fallback for "open in a new tab" when no separate preview origin is available: the document keeps its real type and does render and run, but the sandbox deliberately omits `allow-same-origin`, so it lands in an opaque origin and can reach neither this origin's cookies nor the API. That isolation is also why `localStorage`, `document.cookie` and third-party embeds do not work there.
### Preview on a separate origin
"Open in a new tab" goes through `GET /files/preview-redirect?path=`, which authenticates the caller, then mints a short-lived HMAC token and 302s to a **different origin**:
```text
GET /api/sessions/:sessionId/files/preview-redirect?path=index.html
302 Location: http://localhost:7364/preview/<token>/index.html
GET /preview/<token>/<relative path> (unauthenticated; the token is the credential)
```
- **Why a separate origin.** The page needs a real origin to have working storage, cookies and third-party embeds — but it must not be the app's origin, or Agent-written HTML would run with the session cookie. Locally the app is canonicalized onto `localhost` and previews are served from `127.0.0.1`; cookies are keyed by host and ignore port, so those are separate cookie jars while a second port would not be. Otherwise `PENGUIN_PREVIEW_ORIGIN` applies; with neither (a wildcard or non-loopback bind, or the variable unset), the redirect falls back to the same-origin sandbox above and `previewIsolated` on `GET /api/me` reports `false` so the UI can say so first.
- **The preview host serves only `/preview/*`.** It is the same process as the app, so it answers `/api` with `401` and `302`s every other route to the canonical app host. A session cookie is therefore never set or honored on the preview host, and Agent HTML there cannot reach the API same-origin. (For a deployed `PENGUIN_PREVIEW_ORIGIN`, the reverse proxy must enforce the equivalent: route only `/preview/*` to the app on that origin.)
- **Path-based, not a query parameter**, so a page's relative subresources (`app.js`, `style.css`, images) resolve against the document and load under the same token.
- **The token binds the Session, the preview host and an expiry.** The host binding is load-bearing: the same process also answers on the app origin, so `/preview/...` refuses to serve there — otherwise it would be a same-origin XSS. Access is read-only and scoped to that Session's Workspace, and the path is re-resolved server-side, so `..` and symlink escapes are rejected as before.
- **Responses carry `Referrer-Policy: no-referrer`**, or the token-bearing URL would leak through `Referer` to every third party the page embeds — a risk that exists precisely because embeds now work.
- Bad token, expired token, wrong host and out-of-bounds path all answer a bare 404: the endpoint is unauthenticated and must not confirm what exists.
Key request bodies (explicit keys):
+19 -1
View File
@@ -150,6 +150,7 @@ Schedule 写操作仅限 Owner。新建 Session 模式的任务,`modelId` 与
| POST | /compact | 触发上下文压缩:202;无可压缩内容返回 409 `nothing_to_compact` |
| GET | /files?path= | 浏览 Workspace 目录 |
| GET | /files/content?path=&download=&preview= | 读取 Workspace 文件(`download=1` 时作为附件下载,`preview=1` 以沙箱方式预览 —— 见下) |
| GET | /files/preview-redirect?path= | html 的“新页面打开”:签发令牌并 302 跳转到独立预览源 |
| POST | /files/stat | 批量存在性检查:`{paths}` |
| PUT | /files/content?path= | 上传文件:`{dataBase64}`,上限 14MB |
| GET | /traces | 本 Session 的 Trace 文件列表 |
@@ -167,7 +168,24 @@ Workspace 文件可能由 Agent 生成,`GET /files/content` 一律按不可信
| `preview=1` | 真实类型(`text/html`、`image/svg+xml` 等) | `inline` | `sandbox allow-scripts allow-popups allow-modals allow-forms`,仅对 `.html` / `.htm` / `.svg` 下发 |
| `download=1` | 真实类型 | `attachment` | 无 |
文件名始终以 `filename*=UTF-8''` 形式携带(百分号编码)。`preview=1` 支撑的是“在新标签页打开”:文档保留真实类型,可以正常渲染并执行脚本,但沙箱刻意不含 `allow-same-origin`,因此它落在一个不透明源里,既拿不到本源的 Cookie,也调不动 API;而请求本身仍然要鉴权 —— 顶层 GET 会带上 SameSite=Lax 的会话 Cookie。
文件名始终以 `filename*=UTF-8''` 形式携带(百分号编码)。`preview=1` 用于 Files 面板内的沙箱 iframe 渲染,同时也是“新页面打开”在没有独立预览源时的回退:文档保留真实类型,可以正常渲染并执行脚本,但沙箱刻意不含 `allow-same-origin`,因此它落在一个不透明源里,既拿不到本源的 Cookie,也调不动 API。这份隔离也正是那里 `localStorage`、`document.cookie` 与第三方 embed 全都不可用的原因。
### 独立源预览
“新页面打开”走 `GET /files/preview-redirect?path=`:先鉴权,再签发一枚短时效 HMAC 令牌,然后 302 跳转到**另一个源**:
```text
GET /api/sessions/:sessionId/files/preview-redirect?path=index.html
302 Location: http://localhost:7364/preview/<token>/index.html
GET /preview/<token>/<相对路径> (不鉴权,令牌即凭证)
```
- **为什么要独立源。** 页面需要一个真实的源,才能有可用的 storage、Cookie 与第三方 embed;但它不能是应用自己的源,否则 Agent 写出来的 HTML 就带着会话 Cookie 在跑。本地把 App 固定在规范主机 `localhost`,预览用 `127.0.0.1`——Cookie 按主机划分且不区分端口,所以这两者天然是两个 Cookie jar,而只换端口做不到。其余情况用 `PENGUIN_PREVIEW_ORIGIN`;两者都没有时(通配或非回环绑定,或变量未设)回退到上面的同源沙箱,并由 `GET /api/me` 的 `previewIsolated` 返回 `false`,界面据此提前说明。
- **预览主机只服务 `/preview/*`。** 它与 App 是同一个进程,故其 `/api` 一律 401,其余路径一律 302 回规范 App 主机——会话 Cookie 因此永远不会落在预览主机上,也不被其接受,那里的 Agent HTML 无法同源调用 API。(部署 `PENGUIN_PREVIEW_ORIGIN` 时,反向代理须做等价保证:该源上只把 `/preview/*` 路由到 App。)
- **路径式而非查询参数**,页面里的相对子资源(`app.js`、`style.css`、图片)才能相对文档解析,并在同一个令牌下加载。
- **令牌绑定 Session、预览主机与过期时间。** 其中主机绑定是承重的:同一个进程也在应用源上应答,因此 `/preview/...` 在应用源上一律拒绝服务——否则那就是一个同源 XSS。权限只读、限定该 Session 的 Workspace,路径仍在服务端重新解析,`..` 与符号链接逃逸照旧拒绝。
- **响应带 `Referrer-Policy: no-referrer`**,否则带令牌的 URL 会经 `Referer` 泄漏给页面内嵌的每一个第三方——而这个风险恰恰是因为 embed 现在能用了才出现的。
- 令牌无效、过期、主机不符与路径越界一律返回裸 404:该端点不鉴权,不能确认任何东西是否存在。
关键请求体(明确键名):