24 KiB
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:
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:
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
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 中,现有:
export async function resolveConversationWorkspace(
request: NextRequest
): Promise<ConversationWorkspace> {
const threadId = threadIdFromRequest(request);
const deployment = await getActiveDeployment();
// ...(其余主体不变)
改为:
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
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 2resolveWorkspaceForThread;既有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:
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:
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 增加一行:
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
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:
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 中:
- import 追加:
import { createRenderToken } from "@/lib/server/renderToken";
- GET 中解构加 threadId,并记录实际命中的相对路径。现有:
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}`);
}
改为:
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);
}
- redirect 分支。现有:
const ext = extname(target).slice(1).toLowerCase();
const fileName = basename(target);
const { contentType, disposition, csp } = fileResponseHeaders(
ext,
fileName,
{ download, render }
);
改为(在 ext 之后插入分支,其余不动):
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
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 处理,不在本任务)