docs(webui): implementation plan for workspace render token
This commit is contained in:
@@ -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/<token>/<relpath>`,使相对资源解析、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 <file>`;服务端测试需 `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<ConversationWorkspace>`(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<ConversationWorkspace> {
|
||||
const threadId = threadIdFromRequest(request);
|
||||
const deployment = await getActiveDeployment();
|
||||
// ...(其余主体不变)
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```ts
|
||||
export async function resolveWorkspaceForThread(
|
||||
threadId: string
|
||||
): Promise<ConversationWorkspace> {
|
||||
const deployment = await getActiveDeployment();
|
||||
// ...(原函数其余主体逐行保留,结尾 return { deployment, threadId, ... } 不变)
|
||||
}
|
||||
|
||||
export async function resolveConversationWorkspace(
|
||||
request: NextRequest
|
||||
): Promise<ConversationWorkspace> {
|
||||
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/<token>/<relpath...>`(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"), "<html>MARKER</html>");
|
||||
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("<html>MARKER</html>");
|
||||
});
|
||||
|
||||
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<Uint8Array>;
|
||||
|
||||
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/<name>`),修改时必须保留,且 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"), "<html>R</html>");
|
||||
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 处理,不在本任务)**
|
||||
Reference in New Issue
Block a user