From b6da97ed78dd49410540da0aa70d4ebb2652dfcc Mon Sep 17 00:00:00 2001 From: m4 Date: Sat, 8 Aug 2026 13:46:35 +0800 Subject: [PATCH] docs(webui): implementation plan for workspace render token --- .../2026-08-08-workspace-render-token.md | 705 ++++++++++++++++++ 1 file changed, 705 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-08-workspace-render-token.md diff --git a/docs/superpowers/plans/2026-08-08-workspace-render-token.md b/docs/superpowers/plans/2026-08-08-workspace-render-token.md new file mode 100644 index 0000000..603e94f --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-workspace-render-token.md @@ -0,0 +1,705 @@ +# Workspace Render Token Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 修复 HTML Preview 沙盒中相对资源加载失败——`render=1` 的 html 请求 302 到路径内嵌签名 token 的 `/api/workspace/render//`,使相对资源解析、isCrossOrigin、会话 cookie 三层问题同时关闭。 + +**Architecture:** file 路由在现有认证/授权检查后铸造 10 分钟 HMAC token 并 302;新 token 路由免会话、免 isCrossOrigin(token 即凭证),复用 `fileResponseHeaders` 与 `safeResolve`;`resolveConversationWorkspace` 抽出 `resolveWorkspaceForThread(threadId)` 供两路由共用。 + +**Tech Stack:** Next.js 16 App Router(route handler `params` 为 Promise)、React、Vitest。 + +## Global Constraints + +- 测试命令:`npx vitest run `;服务端测试需 `vi.mock("server-only", () => ({}));` 且 `vi.mock("@/lib/auth", () => ({ authSecret: () => "test-secret-test-secret-test-secret" }));`(32 字符)。 +- token 格式:`base64url(JSON({t: threadId, e: expEpochSeconds})) + "." + base64url(HMAC-SHA256(payload))`;TTL 600 秒;验签先长度预检再 `timingSafeEqual`。 +- token 路由响应固定头:`Referrer-Policy: no-referrer`、`Cache-Control: no-store`、`X-Content-Type-Options: nosniff`;html 用 `fileResponseHeaders(ext, name, { render: true })`,其余扩展 `render: false`。 +- 302 响应:`Cache-Control: no-store`;Location 路径逐段 `encodeURIComponent`。 +- proxy 白名单只加前缀匹配 `pathname.startsWith("/api/workspace/render/")`;token 路由内不做会话/isCrossOrigin 检查。 +- 非 render 行为、`download=1` 优先、非 html 忽略 render——file 路由既有语义全都不变。 +- 设计文档:`docs/superpowers/specs/2026-08-08-workspace-render-token-design.md`。 + +--- + +### Task 1: renderToken 模块 + +**Files:** +- Create: `src/lib/server/renderToken.ts` +- Test: `src/lib/server/renderToken.test.ts` + +**Interfaces:** +- Produces: + - `createRenderToken(threadId: string, ttlSeconds?: number): string`(默认 TTL 600) + - `verifyRenderToken(token: string): { threadId: string } | null` +- Consumes(Task 3、4):token 路由验签取 threadId;file 路由铸造。 + +- [ ] **Step 1: Write the failing test** + +`src/lib/server/renderToken.test.ts`: + +```ts +import { createHmac } from "crypto"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/auth", () => ({ + authSecret: () => "test-secret-test-secret-test-secret", +})); + +const { createRenderToken, verifyRenderToken } = await import("./renderToken"); + +const SECRET = "test-secret-test-secret-test-secret"; + +function sign(payload: unknown): string { + const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const sig = createHmac("sha256", SECRET).update(encoded).digest("base64url"); + return `${encoded}.${sig}`; +} + +describe("renderToken", () => { + it("round-trips a threadId", () => { + const token = createRenderToken("thread-1"); + expect(verifyRenderToken(token)).toEqual({ threadId: "thread-1" }); + }); + + it("round-trips a unicode threadId", () => { + const token = createRenderToken("线程-报告"); + expect(verifyRenderToken(token)).toEqual({ threadId: "线程-报告" }); + }); + + it("rejects a forged payload under a valid-looking signature slot", () => { + const real = createRenderToken("thread-1"); + const sig = real.split(".")[1]; + const forged = Buffer.from( + JSON.stringify({ t: "other-thread", e: Math.floor(Date.now() / 1000) + 600 }) + ).toString("base64url"); + expect(verifyRenderToken(`${forged}.${sig}`)).toBeNull(); + }); + + it("rejects a tampered signature", () => { + const payload = createRenderToken("thread-1").split(".")[0]; + expect(verifyRenderToken(`${payload}.AAAA`)).toBeNull(); + }); + + it("rejects an expired token", () => { + const token = createRenderToken("thread-1", -10); + expect(verifyRenderToken(token)).toBeNull(); + }); + + it("rejects a correctly-signed payload without a threadId", () => { + const token = sign({ t: "", e: Math.floor(Date.now() / 1000) + 600 }); + expect(verifyRenderToken(token)).toBeNull(); + }); + + it("rejects malformed tokens", () => { + expect(verifyRenderToken("")).toBeNull(); + expect(verifyRenderToken("nodot")).toBeNull(); + expect(verifyRenderToken(".sig")).toBeNull(); + expect(verifyRenderToken("!!!.!!!")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/renderToken.test.ts` +Expected: FAIL — `Cannot find module './renderToken'` + +- [ ] **Step 3: Write the implementation** + +`src/lib/server/renderToken.ts`: + +```ts +import "server-only"; + +import { createHmac, randomBytes, timingSafeEqual } from "crypto"; +import { authSecret } from "@/lib/auth"; + +export const RENDER_TOKEN_TTL_SECONDS = 600; + +// When auth is disabled there is no configured secret to sign with; fall back +// to a process-lifetime random key. A restart then invalidates outstanding +// tokens, which is acceptable at a 10-minute TTL. +let fallbackSecret: string | null = null; +function tokenSecret(): string { + return authSecret() ?? (fallbackSecret ??= randomBytes(32).toString("base64url")); +} + +export function createRenderToken( + threadId: string, + ttlSeconds: number = RENDER_TOKEN_TTL_SECONDS +): string { + const payload = Buffer.from( + JSON.stringify({ t: threadId, e: Math.floor(Date.now() / 1000) + ttlSeconds }) + ).toString("base64url"); + const sig = createHmac("sha256", tokenSecret()).update(payload).digest("base64url"); + return `${payload}.${sig}`; +} + +export function verifyRenderToken(token: string): { threadId: string } | null { + const dot = token.indexOf("."); + if (dot <= 0) return null; + const payload = token.slice(0, dot); + const sig = token.slice(dot + 1); + const expected = createHmac("sha256", tokenSecret()) + .update(payload) + .digest("base64url"); + const sigBuf = Buffer.from(sig); + const expectedBuf = Buffer.from(expected); + if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) { + return null; + } + try { + const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { + t?: unknown; + e?: unknown; + }; + if (typeof parsed.t !== "string" || parsed.t.length === 0) return null; + if (typeof parsed.e !== "number" || parsed.e <= Math.floor(Date.now() / 1000)) { + return null; + } + return { threadId: parsed.t }; + } catch { + return null; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/server/renderToken.test.ts` +Expected: PASS (7 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/renderToken.ts src/lib/server/renderToken.test.ts +git commit -m "feat(webui): HMAC render token for workspace html preview" +``` + +--- + +### Task 2: 抽出 `resolveWorkspaceForThread` + +**Files:** +- Modify: `src/lib/server/conversationWorkspace.ts` + +**Interfaces:** +- Produces: `resolveWorkspaceForThread(threadId: string): Promise`(Task 3 的 token 路由用)。 +- 不变:`resolveConversationWorkspace(request)` 签名与行为(file/upload/download 等既有调用方不受影响)。 + +说明:纯重构,把现函数主体(`getActiveDeployment()` 起的全部逻辑)移入新函数,原函数变为薄封装。 + +- [ ] **Step 1: Apply the refactor** + +`src/lib/server/conversationWorkspace.ts` 中,现有: + +```ts +export async function resolveConversationWorkspace( + request: NextRequest +): Promise { + const threadId = threadIdFromRequest(request); + const deployment = await getActiveDeployment(); + // ...(其余主体不变) +``` + +改为: + +```ts +export async function resolveWorkspaceForThread( + threadId: string +): Promise { + const deployment = await getActiveDeployment(); + // ...(原函数其余主体逐行保留,结尾 return { deployment, threadId, ... } 不变) +} + +export async function resolveConversationWorkspace( + request: NextRequest +): Promise { + return resolveWorkspaceForThread(threadIdFromRequest(request)); +} +``` + +- [ ] **Step 2: Typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 3: Run the suite (guard against refactor breakage)** + +Run: `npm test` +Expected: 全部通过 + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/server/conversationWorkspace.ts +git commit -m "refactor(webui): extract resolveWorkspaceForThread from request-bound resolver" +``` + +--- + +### Task 3: token 渲染路由 + proxy 白名单 + +**Files:** +- Create: `src/app/api/workspace/render/[token]/[...path]/route.ts` +- Test: `src/app/api/workspace/render/[token]/[...path]/route.test.ts` +- Modify: `src/proxy.ts`(`isPublicPath` 加一行前缀匹配) + +**Interfaces:** +- Consumes: Task 1 `verifyRenderToken`;Task 2 `resolveWorkspaceForThread`;既有 `fileResponseHeaders`、`safeResolve`。 +- Produces: `GET /api/workspace/render//`(Task 4 302 的目标)。 + +- [ ] **Step 1: Write the failing test** + +`src/app/api/workspace/render/[token]/[...path]/route.test.ts`: + +```ts +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { promises as fs } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { NextRequest } from "next/server"; + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/auth", () => ({ + authSecret: () => "test-secret-test-secret-test-secret", +})); + +const filesDir = join(tmpdir(), `evosci-render-route-test-${process.pid}`); + +vi.mock("@/lib/server/conversationWorkspace", () => ({ + ConversationWorkspaceError: class ConversationWorkspaceError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + } + }, + resolveWorkspaceForThread: async (threadId: string) => ({ + deployment: {}, + threadId, + scopeId: "scope-1", + filesDir, + runtimeDir: filesDir, + }), +})); + +const routes = await import("./route"); +const { createRenderToken } = await import("@/lib/server/renderToken"); + +type Params = { token: string; path: string[] }; +function ctx(params: Params) { + return { params: Promise.resolve(params) }; +} +function get(params: Params) { + return routes.GET( + new NextRequest( + `http://localhost/api/workspace/render/${params.token}/${params.path.join("/")}` + ), + ctx(params) + ); +} + +afterAll(async () => { + await fs.rm(filesDir, { recursive: true, force: true }); +}); + +describe("GET /api/workspace/render/[token]/[...path]", () => { + beforeEach(async () => { + await fs.rm(filesDir, { recursive: true, force: true }); + await fs.mkdir(join(filesDir, "dir"), { recursive: true }); + await fs.writeFile(join(filesDir, "dir", "report.html"), "MARKER"); + await fs.writeFile(join(filesDir, "dir", "style.css"), "body{}"); + await fs.writeFile(join(filesDir, "dir", "app.js"), "console.log(1)"); + }); + + it("serves html in render mode with a no-referrer policy", async () => { + const res = await get({ token: createRenderToken("t1"), path: ["dir", "report.html"] }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(res.headers.get("content-security-policy")).toBe("sandbox allow-scripts"); + expect(res.headers.get("referrer-policy")).toBe("no-referrer"); + expect(res.headers.get("x-content-type-options")).toBe("nosniff"); + expect(res.headers.get("cache-control")).toBe("no-store"); + expect(await res.text()).toBe("MARKER"); + }); + + it("serves css and js subresources with executable MIME types", async () => { + const token = createRenderToken("t1"); + const css = await get({ token, path: ["dir", "style.css"] }); + expect(css.headers.get("content-type")).toBe("text/css; charset=utf-8"); + const js = await get({ token, path: ["dir", "app.js"] }); + expect(js.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); + }); + + it("rejects an invalid token with 403", async () => { + const res = await get({ token: "bogus.sig", path: ["dir", "report.html"] }); + expect(res.status).toBe(403); + }); + + it("rejects an expired token with 403", async () => { + const res = await get({ token: createRenderToken("t1", -10), path: ["dir", "report.html"] }); + expect(res.status).toBe(403); + }); + + it("rejects path traversal outside the workspace", async () => { + const res = await get({ + token: createRenderToken("t1"), + path: ["..", "..", "..", "etc", "passwd"], + }); + expect(res.status).toBe(400); + }); + + it("rejects a missing file", async () => { + const res = await get({ token: createRenderToken("t1"), path: ["nope.txt"] }); + expect(res.status).toBe(400); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run "src/app/api/workspace/render/[token]/[...path]/route.test.ts"` +Expected: FAIL — `Cannot find module './route'` + +- [ ] **Step 3: Write the implementation** + +`src/app/api/workspace/render/[token]/[...path]/route.ts`: + +```ts +import { createReadStream, promises as fs } from "fs"; +import { basename, extname } from "path"; +import { Readable } from "stream"; +import { NextRequest, NextResponse } from "next/server"; +import { safeResolve } from "@/lib/server/workspace"; +import { + ConversationWorkspaceError, + resolveWorkspaceForThread, +} from "@/lib/server/conversationWorkspace"; +import { verifyRenderToken } from "@/lib/server/renderToken"; +import { fileResponseHeaders } from "@/lib/server/workspaceFileHeaders"; + +export const runtime = "nodejs"; + +type RouteContext = { params: Promise<{ token: string; path: string[] }> }; + +// The path-embedded HMAC token is the credential here: this route is +// session-free (proxy whitelist) and skips isCrossOrigin so a sandboxed +// opaque-origin iframe's subresources can load. TTL + thread binding + CSP +// sandbox bound what a leaked token can do. +export async function GET(request: NextRequest, context: RouteContext) { + try { + const { token, path } = await context.params; + const verified = verifyRenderToken(token); + if (!verified) { + return NextResponse.json( + { error: "Invalid or expired render token." }, + { status: 403 } + ); + } + const relPath = path.join("/"); + const { filesDir } = await resolveWorkspaceForThread(verified.threadId); + const target = await safeResolve(filesDir, relPath); + const stat = await fs.stat(target); + if (!stat.isFile()) { + return NextResponse.json({ error: "Not a file." }, { status: 400 }); + } + + const ext = extname(target).slice(1).toLowerCase(); + const fileName = basename(target); + const { contentType, disposition, csp } = fileResponseHeaders(ext, fileName, { + // html subresources (multi-page reports) render too; everything else + // keeps its download-mode headers. + render: ext === "html", + }); + + const nodeStream = createReadStream(target); + const webStream = Readable.toWeb(nodeStream) as ReadableStream; + + return new NextResponse(webStream, { + headers: { + "Content-Type": contentType, + "Content-Length": String(stat.size), + "Content-Disposition": disposition, + "Content-Security-Policy": csp, + // The rendered page must not leak its token-bearing URL to external + // CDNs via Referer. + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "Cache-Control": "no-store", + }, + }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to read file." }, + { status: error instanceof ConversationWorkspaceError ? error.status : 400 } + ); + } +} +``` + +`src/proxy.ts` 的 `isPublicPath` 增加一行: + +```ts + pathname === "/api/usage/capabilities" || + pathname.startsWith("/api/workspace/render/") || + pathname.startsWith("/_next/") || +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run "src/app/api/workspace/render/[token]/[...path]/route.test.ts"` +Expected: PASS (6 tests) + +- [ ] **Step 5: Typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 6: Commit** + +```bash +git add "src/app/api/workspace/render" src/proxy.ts +git commit -m "feat(webui): token-gated render route for sandboxed html preview" +``` + +--- + +### Task 4: file 路由 render=1 改为 302 + token + +**Files:** +- Modify: `src/app/api/workspace/file/route.ts`(GET 内 ext 计算之后插入 redirect 分支;`resolveConversationWorkspace` 解构加 `threadId`;`safeResolve` 段记录实际命中的相对路径) +- Test: `src/app/api/workspace/file/route.test.ts`(新建) + +**Interfaces:** +- Consumes: Task 1 `createRenderToken`。 +- Produces: iframe 现有 src(`workspaceFileUrl(threadId, path) + "&render=1"`)经 302 落到 Task 3 路由;客户端零改动。 + +注意:该文件工作区中有用户未提交的 artifacts 回退 hunk(`safeResolve` 失败时对裸文件名重试 `artifacts/`),修改时必须保留,且 redirect 要使用**实际命中**的相对路径。 + +- [ ] **Step 1: Write the failing test** + +`src/app/api/workspace/file/route.test.ts`: + +```ts +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { promises as fs } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { NextRequest } from "next/server"; + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/auth", () => ({ + authSecret: () => "test-secret-test-secret-test-secret", +})); + +const filesDir = join(tmpdir(), `evosci-file-route-test-${process.pid}`); + +vi.mock("@/lib/server/conversationWorkspace", () => ({ + ConversationWorkspaceError: class ConversationWorkspaceError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + } + }, + resolveConversationWorkspace: async () => ({ + deployment: {}, + threadId: "thread-1", + scopeId: "scope-1", + filesDir, + runtimeDir: filesDir, + }), +})); + +const routes = await import("./route"); +const { verifyRenderToken } = await import("@/lib/server/renderToken"); + +function get(query: string) { + return routes.GET( + new NextRequest(`http://localhost/api/workspace/file?threadId=thread-1&${query}`) + ); +} + +function redirectToken(res: Response): { token: string; rel: string } { + const location = res.headers.get("location"); + expect(location).toBeTruthy(); + const match = new URL(location!).pathname.match( + /^\/api\/workspace\/render\/([^/]+)\/(.+)$/ + ); + expect(match).toBeTruthy(); + return { token: match![1], rel: decodeURIComponent(match![2]) }; +} + +afterAll(async () => { + await fs.rm(filesDir, { recursive: true, force: true }); +}); + +describe("GET /api/workspace/file?render=1", () => { + beforeEach(async () => { + await fs.rm(filesDir, { recursive: true, force: true }); + await fs.mkdir(join(filesDir, "dir"), { recursive: true }); + await fs.writeFile(join(filesDir, "dir", "report.html"), "R"); + await fs.writeFile(join(filesDir, "notes.md"), "# hi"); + }); + + it("redirects html render=1 to a path-embedded token URL", async () => { + const res = await get("path=dir/report.html&render=1"); + expect(res.status).toBe(302); + expect(res.headers.get("cache-control")).toBe("no-store"); + const { token, rel } = redirectToken(res); + expect(rel).toBe("dir/report.html"); + expect(verifyRenderToken(token)).toEqual({ threadId: "thread-1" }); + }); + + it("ignores render for non-html files", async () => { + const res = await get("path=notes.md&render=1"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/markdown; charset=utf-8"); + expect(await res.text()).toBe("# hi"); + }); + + it("lets download take precedence over render for html", async () => { + const res = await get("path=dir/report.html&render=1&download=1"); + expect(res.status).toBe(200); + expect(res.headers.get("content-disposition")).toMatch(/^attachment;/); + expect(res.headers.get("content-type")).toBe("text/plain; charset=utf-8"); + }); + + it("keeps plain html (no render) as text/plain with a full sandbox", async () => { + const res = await get("path=dir/report.html"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/plain; charset=utf-8"); + expect(res.headers.get("content-security-policy")).toBe("sandbox"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/app/api/workspace/file/route.test.ts` +Expected: FAIL — 首个用例得到 200 而非 302(redirect 分支尚未实现) + +- [ ] **Step 3: Write the implementation** + +`src/app/api/workspace/file/route.ts` 中: + +1. import 追加: + +```ts +import { createRenderToken } from "@/lib/server/renderToken"; +``` + +2. GET 中解构加 threadId,并记录实际命中的相对路径。现有: + +```ts + const { filesDir: workspaceDir } = await resolveConversationWorkspace(request); + // safeResolve canonicalizes + re-checks containment, so a symlink can't be + // used to read a file outside the workspace (or a hidden/internal entry). + let target: string; + try { + target = await safeResolve(workspaceDir, relPath); + } catch (error) { + // Agents sometimes cite a file by bare name when it actually lives + // under the conventional artifacts/ dir — retry there before failing. + const bare = relPath.replace(/^\/+/, ""); + if (bare.includes("/")) throw error; + target = await safeResolve(workspaceDir, `artifacts/${bare}`); + } +``` + +改为: + +```ts + const { filesDir: workspaceDir, threadId } = await resolveConversationWorkspace(request); + // safeResolve canonicalizes + re-checks containment, so a symlink can't be + // used to read a file outside the workspace (or a hidden/internal entry). + let target: string; + let resolvedRel = relPath; + try { + target = await safeResolve(workspaceDir, relPath); + } catch (error) { + // Agents sometimes cite a file by bare name when it actually lives + // under the conventional artifacts/ dir — retry there before failing. + const bare = relPath.replace(/^\/+/, ""); + if (bare.includes("/")) throw error; + resolvedRel = `artifacts/${bare}`; + target = await safeResolve(workspaceDir, resolvedRel); + } +``` + +3. redirect 分支。现有: + +```ts + const ext = extname(target).slice(1).toLowerCase(); + const fileName = basename(target); + const { contentType, disposition, csp } = fileResponseHeaders( + ext, + fileName, + { download, render } + ); +``` + +改为(在 ext 之后插入分支,其余不动): + +```ts + const ext = extname(target).slice(1).toLowerCase(); + + // render=1 for html bounces through a short-lived, path-embedded token. + // The sandboxed iframe's subresources can't carry our session cookie and + // trip isCrossOrigin; the token URL gives them a servable prefix that + // relative paths resolve against (download=1 keeps its direct stream). + if (ext === "html" && render && !download) { + const token = createRenderToken(threadId); + const encodedPath = resolvedRel.split("/").map(encodeURIComponent).join("/"); + const res = NextResponse.redirect( + new URL(`/api/workspace/render/${token}/${encodedPath}`, request.url), + 302 + ); + // The token expires in minutes; never let a cache replay a stale redirect. + res.headers.set("Cache-Control", "no-store"); + return res; + } + + const fileName = basename(target); + const { contentType, disposition, csp } = fileResponseHeaders( + ext, + fileName, + { download, render } + ); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/app/api/workspace/file/route.test.ts` +Expected: PASS (4 tests) + +- [ ] **Step 5: Typecheck + guard tests** + +Run: `npx tsc --noEmit && npx vitest run "src/app/api/workspace/render/[token]/[...path]/route.test.ts" src/lib/server/renderToken.test.ts src/lib/server/workspaceFileHeaders.test.ts` +Expected: no errors; all PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/app/api/workspace/file/route.ts src/app/api/workspace/file/route.test.ts +git commit -m "feat(webui): redirect render=1 html to path-embedded token URL" +``` + +--- + +### Task 5: 全量回归 + +- [ ] **Step 1: Run the full test suite** + +Run: `npm test` +Expected: 全部通过 + +- [ ] **Step 2: Typecheck** + +Run: `npx tsc --noEmit` +Expected: no errors + +- [ ] **Step 3: Manual verification(controller 处理,不在本任务)**