Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ AGENTFLOW_ASSISTANT_MODELS=
AGENTFLOW_ASSISTANT_MODEL=
# Worker separation, installed runners and preview settings retain AGENTFLOW_ names.
# See docs/deployment.md. Production HTTPS: AGENTFLOW_SECURE_COOKIES=true.

# Archived previews require a separate hostname; see docs/deployment.md#isolated-preview-host.
# AGENTFLOW_PREVIEW_PUBLIC_URL=https://preview.example.net
# AGENTFLOW_PREVIEW_FRAME_ANCESTORS=https://studio.example.com
2 changes: 1 addition & 1 deletion .github/workflows/platform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
REAL_BROWSER_TESTS: '^(TestMainBotBrowserStartsAndDrivesRunBrowser|TestBrowserStreamPushesFramesAndRunsCommands|TestRunnerStartsPanelBrowserInRunSandbox|TestBrowserAgentOperationsDriveRealChromium)$'
REAL_BROWSER_TESTS: '^(TestMainBotBrowserStartsAndDrivesRunBrowser|TestBrowserStreamPushesFramesAndRunsCommands|TestRunnerStartsPanelBrowserInRunSandbox|TestBrowserAgentOperationsDriveRealChromium|TestPreviewIsolationBrowser)$'
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-go@v6
Expand Down
1 change: 1 addition & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ services:
AGENTFLOW_ASSISTANT_MODEL: ${AGENTFLOW_ASSISTANT_MODEL:-}
AGENTFLOW_ASSISTANT_MODELS: ${AGENTFLOW_ASSISTANT_MODELS:-}
AGENTFLOW_PREVIEW_PUBLIC_URL: ${AGENTFLOW_PREVIEW_PUBLIC_URL:-}
AGENTFLOW_PREVIEW_FRAME_ANCESTORS: ${AGENTFLOW_PREVIEW_FRAME_ANCESTORS:-}
AGENTFLOW_PLATFORM_MCP_URL: ${AGENTFLOW_PLATFORM_MCP_URL:-http://platform:8080}
AGENTFLOW_DOCKER_NETWORK: ${COMPOSE_PROJECT_NAME:-loopforge}_default
AGENTFLOW_LOCAL_SANDBOX: docker
Expand Down
27 changes: 27 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,33 @@ A platform administrator can share one CodeBuddy API Key under **Platform admini

Configure provider credentials in the platform UI, not the host's global CLI configuration. Credentials are bound to the responsible actor by the upstream runtime. The home composer and SOP roles support CodeBuddy, Codex and Claude Code independently. Select the executor beside the home model selector; its API key (and optional Codex/Claude base URL) comes from your personal settings. Changing the executor preserves the draft; saved conversations record the executor used. Main Bot writes still pass through the platform tool gateway and its approval checks. Requirement clarification and notifications continue to use CodeBuddy. A scheduled assistant task can select any supported CLI and runs as an ordinary chat turn; Codex and Claude Code still need that account's own credential. Live model tests are opt-in; set `AGENTFLOW_LIVE_TESTS=1` only for the acceptance suites that need to load local credentials.

## Isolated preview host

Archived frontend previews execute agent-produced HTML and JavaScript. Serve them on a dedicated hostname, separate from every studio hostname:

```dotenv
AGENTFLOW_PREVIEW_PUBLIC_URL=https://preview.example.net
AGENTFLOW_PREVIEW_FRAME_ANCESTORS=https://studio.example.com
```

`FRAME_ANCESTORS` is a space-separated list of exact studio origins, including schemes and non-default ports. Wildcards and `'self'` are rejected. A different port on the same hostname is insufficient because cookies are not port-scoped. Prefer a separate registrable domain and keep studio cookies host-only. Do not configure studio APIs, authentication proxies, or other trusted applications on the preview hostname.

Route both hostnames to the platform, preserving the original `Host`. For example, a Caddy reverse proxy on the platform host can use:

```caddyfile
studio.example.com, preview.example.net {
reverse_proxy 127.0.0.1:8090
}
```

Configure DNS and TLS for both names. Compose forwards both variables; restart with `./start.sh` after changing `.env`. Native deployments export the same variables. For local HTTP development, use `http://localhost:8090` for previews and `http://127.0.0.1:8090` for the studio, listing the latter in `FRAME_ANCESTORS`.

The studio's iframe policy allows the validated preview origin. Preview frames, copied links and standalone windows use the server-issued URL unchanged. When the studio uses HTTPS, configure an HTTPS preview URL too; browsers block HTTP frames as mixed content.

The server serves preview tokens only on the preview hostname and returns 404 for platform pages/APIs there. Session creation fails with a configuration error until an isolated origin and the requesting studio hostname are configured. Archiving and the authenticated ZIP download API remain available. Existing same-origin preview links stop working; reopen the preview after configuring the isolated host. No SQL migration or runner upgrade is needed.

Preview bundles can use ES modules, local styles/assets and the existing History API routing shim. Bundle external scripts, styles and fonts locally. CSP blocks nested frames, workers/service workers, and resource requests to the studio; backend connections require a configured backend URL on a non-studio hostname and that backend's CORS support. Preview/backend cookies and storage are separate from studio credentials. The optional backend interceptor is a convenience, not an isolation boundary. Previews share their dedicated origin with each other; this does not provide isolation between preview producers.

## Observability schema

After the base tables have been created, explicitly run `bash scripts/install-observability-schema.sh` for the Compose database. It installs absent `run_telemetry.adapter` and `run_logs.level` columns and their indexes from upstream migration `001_run_obs_generated_columns.sql`. The local development installer calls this step before installing templates. It can be repeated without reapplying existing columns. Application startup does not run data migrations.
Expand Down
26 changes: 14 additions & 12 deletions docs/proposals/frontend-preview-delivery-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,17 +375,17 @@ Runner 离线时,preview 显示“等待执行机器上线”。超过 finaliz

## 13. Preview Gateway 与安全边界

### 13.1 路径托管,不用独立 Origin
### 13.1 独立 Origin 下的路径托管

未配置 wildcard DNS 的部署不能使用 `https://<token>.preview.example.com/`。当前 MVP 使用同 Host 路径:
无需 wildcard DNS;配置一个独立于工作台的 hostname,令牌仍放在路径中:

```text
https://loopforge.example.com/preview/<session-token>/
https://preview.example.net/preview/<session-token>/
```

不要再引入 `AGENTFLOW_PREVIEW_ENABLED`、`AGENTFLOW_PREVIEW_ROOT`、`AGENTFLOW_PREVIEW_BASE_DOMAIN`。预览默认开启;静态包进 MySQL;`AGENTFLOW_PREVIEW_PUBLIC_URL` 只覆盖对外 origin(例如反代后的 `https://loopforge.example.com`)。
不要再引入 `AGENTFLOW_PREVIEW_ENABLED`、`AGENTFLOW_PREVIEW_ROOT`、`AGENTFLOW_PREVIEW_BASE_DOMAIN`。归档默认开启,静态包进 MySQL。在线预览必须配置独立的 `AGENTFLOW_PREVIEW_PUBLIC_URL`(如 `https://preview.example.net`),并在 `AGENTFLOW_PREVIEW_FRAME_ANCESTORS` 中列出完整工作台 origin(如 `https://studio.example.com`)。禁止同一 hostname 换端口、通配符或 `self`;部署配置见 [deployment](../deployment.md#isolated-preview-host)。未配置时不签发预览会话,归档和鉴权 ZIP 下载 API 仍可用。

预览 HTML 仍是不可信内容。用短期 token、CSP `sandbox` + `connect-src`、以及运行时拦截 fetch/XHR 来约束它能访问的后端。主站 API 不在预览 token 路径下。
预览 HTML 仍是不可信内容。隔离依赖独立 origin、预览域名的平台路由拒绝和浏览器 CSP。短期 token 控制预览访问;运行时拦截 fetch/XHR 仅提供后端路径映射。

### 13.2 访问会话

Expand All @@ -404,15 +404,17 @@ Studio 调用 `POST /api/runs/:id/preview/session`。服务端验证用户对 Ru

```html
<iframe
src="/preview/<token>/"
src="https://preview.example.net/preview/<token>/"
sandbox="allow-scripts allow-forms allow-modals allow-downloads allow-same-origin"
referrerpolicy="no-referrer"
></iframe>
```

路径托管下必须开 `allow-same-origin`,否则 Vite ESM 无法加载。不要添加 `allow-top-navigation`。剪贴板、摄像头、麦克风和定位默认不授权。
预览使用独立 hostname;`allow-same-origin` 保留 ES modules 和 History API 兼容性。主站上的 `/preview/<token>/` 返回 404,预览 hostname 上所有平台页面与 API 返回 404。不要添加 `allow-top-navigation`。剪贴板、摄像头、麦克风和定位默认不授权。

代价是预览与平台同源:预览脚本可以操作外层 Studio 页面(读写 DOM、借 `parent.fetch` 以当前登录用户身份调平台接口),CSP 的 `connect-src` 只约束预览文档自身。
威胁模型:同项目成员或 Agent 构建产物可包含不可信脚本,查看者可能已登录工作台。浏览器同源策略阻止预览读写工作台父窗口或调用 `parent.fetch`;CSP 限制 fetch、表单、图片、脚本等资源请求,后端 URL 禁止指向任何配置的工作台 hostname。资源必须打包在静态包内,子 iframe 和 worker/service worker 禁用。已有会话也在读取时重新校验后端地址。后端拦截脚本不承担安全边界。预览之间仍共享同一个独立 origin,并不提供预览间租户隔离。

`TestPreviewIsolationBrowser` 通过实际归档、网关与 Chromium 验证带签名登录 Cookie 的查看者、父窗口访问、原生 fetch/no-cors/图片请求、模块与样式加载、History API 路由,以及独立窗口。

### 13.4 静态响应

Expand All @@ -430,10 +432,10 @@ Studio 调用 `POST /api/runs/:id/preview/session`。服务端验证用户对 Ru
路径托管下 `location.pathname` 只有一个值:应用要的是 `/about`,地址栏、刷新和 Referer 要的是 `/preview/<token>/about`。垫片只能在一段时间内二选一,已知限制:

- 业务代码在启动后直接读 `location.pathname`,读到的是带前缀的路径。
- 有路由的包在路由启动前,独立窗口地址栏显示去掉前缀的地址,这时用浏览器刷新或复制地址会落到平台;load 后 10 秒仍未启动的路由会读到带前缀的路径。
- 启动期间在新标签页打开链接、提交 POST 表单,以及不支持 Navigation API 的浏览器里的脚本跳转,仍会落到平台。
- 有路由的包在路由启动前,独立窗口地址栏显示去掉前缀的地址,这时用浏览器刷新或复制地址会在预览域名返回 404;load 后 10 秒仍未启动的路由会读到带前缀的路径。
- 启动期间在新标签页打开链接、提交 POST 表单,以及不支持 Navigation API 的浏览器里的脚本跳转,仍会在预览域名返回 404。

独立 preview host 可以自然解决绝对 `/assets/*`、前端路由和 service worker base path 问题,比 path-based proxy 兼容性更高。
当前独立 hostname 仍使用带令牌的路径,保留上述路由限制;service worker 禁用。后续若采用每个预览独立的 origin,需另行定义路由和存储隔离契约。

## 14. Studio 交互

Expand Down Expand Up @@ -649,7 +651,7 @@ preview.deleted
- 实现本地 Preview Finalizer。
- 实现 Runner `archive_preview` 和流式上传。
- 增加 cleanup hold、超时和启动恢复。
- 实现路径 `/preview/{token}/` 静态服务,不用独立 origin。
- 在独立预览 hostname 实现 `/preview/{token}/` 静态服务,主站不托管可执行预览。
- Studio 增加“前端预览”Tab。
- 暂不截图、不支持运行时容器。

Expand Down
8 changes: 5 additions & 3 deletions internal/engine/preview_finalizer_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package engine

import (
"net/http/httptest"
"net/url"
"os"
"path/filepath"
Expand All @@ -16,8 +17,9 @@ import (
func enablePreview(t *testing.T, engine *Engine, store *repo.Store) *preview.Service {
t.Helper()
svc := preview.NewService(store, preview.NewDBStore(store), preview.Config{
PublicScheme: "http",
FrameAncestors: "'self'",
PublicScheme: "https",
PublicHost: "preview.example",
FrameAncestors: "https://studio.example",
MaxFiles: 50,
MaxCompressed: 1 << 20,
MaxExpanded: 4 << 20,
Expand Down Expand Up @@ -372,7 +374,7 @@ func TestDeleteRunRevokesPreviewSession(t *testing.T) {
if ready.Status != domain.PreviewReady {
t.Fatalf("preview not ready: %+v", ready)
}
session, urlStr, err := svc.CreateSession(ready, nil, "")
session, urlStr, err := svc.CreateSession(ready, httptest.NewRequest("POST", "https://studio.example/", nil), "")
if err != nil || urlStr == "" || session.ID == "" {
t.Fatalf("session %v %s", err, urlStr)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/auth_binary_cases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func TestGinMiddlewareAndSmallViews(t *testing.T) {
}

router := gin.New()
router.Use(securityHeaders(), gzipMaybe())
router.Use(securityHeaders(nil), gzipMaybe())
router.GET("/asset.js", func(c *gin.Context) { c.String(http.StatusOK, "javascript") })
router.GET("/api/example/stream", func(c *gin.Context) { c.String(http.StatusOK, "event") })
req := httptest.NewRequest(http.MethodGet, "/asset.js?v=1", nil)
Expand Down
9 changes: 8 additions & 1 deletion internal/httpapi/gin_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"agentflow/internal/apperr"
"agentflow/internal/engine"
"agentflow/internal/preview"

"github.com/gin-gonic/gin"
)
Expand Down Expand Up @@ -117,14 +118,20 @@ func cors() gin.HandlerFunc {
}
}

func securityHeaders() gin.HandlerFunc {
func securityHeaders(previews *preview.Service) gin.HandlerFunc {
previewOrigin := previews.PublicOrigin()
return func(c *gin.Context) {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
path := c.Request.URL.Path
frameOptions := "DENY"
csp := "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; frame-src 'self' blob:"
if (path == "/" || path == "/index.html") && previewOrigin != "" {
// The studio must allow the same origin the preview gateway serves.
// Other resource policies and the preview's sandbox remain unchanged.
csp += " " + previewOrigin
}
if strings.HasPrefix(path, "/chatbot/") {
frameOptions = "SAMEORIGIN"
csp = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; frame-src 'self' blob:; form-action 'self'"
Expand Down
Loading