From b59244e0b7c4841f38c65383da56f6e69b9c4530 Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Tue, 6 Oct 2026 17:52:19 +0800 Subject: [PATCH 1/6] fix(studio-server): keep the desktop app's hand-off and chat history out of the file routes The file and preview routes reach any path inside the project. The desktop app keeps two files there that hold the person's agent session and their chat: .hyperframes/agent-handoff.json and .hyperframes/app-history.jsonl. No route now reads, writes, renames onto, duplicates or uploads over them, by any spelling or through a link. The rest of .hyperframes/ (Studio's motion file, prepared GIF assets) is served as before. --- .../src/helpers/safePath.test.ts | 12 ++- .../studio-server/src/helpers/safePath.ts | 22 ++++- .../src/routes/files.pathSafety.test.ts | 83 +++++++++++++++++++ packages/studio-server/src/routes/files.ts | 3 +- .../studio-server/src/routes/preview.test.ts | 20 +++++ packages/studio-server/src/routes/preview.ts | 8 +- 6 files changed, 142 insertions(+), 6 deletions(-) diff --git a/packages/studio-server/src/helpers/safePath.test.ts b/packages/studio-server/src/helpers/safePath.test.ts index 6afbea122ac..ca5abd8a0c2 100644 --- a/packages/studio-server/src/helpers/safePath.test.ts +++ b/packages/studio-server/src/helpers/safePath.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { walkDir } from "./safePath"; +import { isPrivateProjectFile, walkDir } from "./safePath"; const hooks = vi.hoisted(() => ({ unreadable: new Map() })); vi.mock("node:fs", async (importOriginal) => { @@ -82,3 +82,13 @@ describe("walkDir", () => { expect(() => walkDir(projectDir)).toThrow("EACCES"); }); }); + +describe("isPrivateProjectFile", () => { + it("names the desktop app's hand-off and chat history, in any case, and nothing else", () => { + expect(isPrivateProjectFile("/p", "/p/.hyperframes/agent-handoff.json")).toBe(true); + expect(isPrivateProjectFile("/p", "/p/.HyperFrames/App-History.JSONL")).toBe(true); + expect(isPrivateProjectFile("/p", "/p/.hyperframes/studio-motion.json")).toBe(false); + expect(isPrivateProjectFile("/p", "/p/.hyperframes/prepared-assets/gif/a.mp4")).toBe(false); + expect(isPrivateProjectFile("/p", "/p/agent-handoff.json")).toBe(false); + }); +}); diff --git a/packages/studio-server/src/helpers/safePath.ts b/packages/studio-server/src/helpers/safePath.ts index f6ff3510ebf..f1fd7177072 100644 --- a/packages/studio-server/src/helpers/safePath.ts +++ b/packages/studio-server/src/helpers/safePath.ts @@ -1,6 +1,6 @@ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; import { readdirSync, type Dirent } from "node:fs"; -import { realpath, resolveWithinProject } from "@hyperframes/core/safe-path"; +import { realpath, resolveWithinProject as resolveInProject } from "@hyperframes/core/safe-path"; // `isSafePath` lives at the package root so non-studio-api layers (compiler, // CLI, engine) can share it without a backwards dependency on studio-api. @@ -12,9 +12,14 @@ export { mkdirWithinProject, realpath, realProjectRoot, - resolveWithinProject, } from "@hyperframes/core/safe-path"; +/** Core's `resolveWithinProject`, closed to the desktop app's private files for every route a request path reaches. */ +export function resolveWithinProject(base: string, relativePath: string): string | null { + const resolved = resolveInProject(base, relativePath); + return resolved && !isPrivateProjectFile(base, resolved) ? resolved : null; +} + /** The real path; for a path not there (yet, or any more), the nearest existing folder's real path plus the rest. */ export function realFilePath(filePath: string): string { try { @@ -25,6 +30,19 @@ export function realFilePath(filePath: string): string { } } +// The desktop app's hand-off and chat history: no route reads or writes them, by any spelling or through a link. +const PRIVATE_FILES = new Set([ + ".hyperframes/agent-handoff.json", + ".hyperframes/app-history.jsonl", +]); + +export function isPrivateProjectFile(projectDir: string, filePath: string): boolean { + const rel = (from: string, to: string) => relative(from, to).split(sep).join("/").toLowerCase(); + return [rel(projectDir, filePath), rel(realFilePath(projectDir), realFilePath(filePath))].some( + (path) => PRIVATE_FILES.has(path), + ); +} + /** * `resolveWithinProject` with every link inside the project resolved now, so a write to the result lands on the * file checked here even if a link on the way is retargeted before the write. diff --git a/packages/studio-server/src/routes/files.pathSafety.test.ts b/packages/studio-server/src/routes/files.pathSafety.test.ts index f7cb5cdf76a..dd9fb1dc79b 100644 --- a/packages/studio-server/src/routes/files.pathSafety.test.ts +++ b/packages/studio-server/src/routes/files.pathSafety.test.ts @@ -695,3 +695,86 @@ describe("rename reference updates", () => { }, ); }); + +describe("the desktop app's private files", () => { + it("are never read, written or deleted through the file routes, however the path is spelled", async (context) => { + const { app, project } = fixture(); + mkdirSync(join(project, ".hyperframes")); + writeFileSync( + join(project, ".hyperframes", "agent-handoff.json"), + '{"engine":"claude","sessionId":"secret"}', + ); + writeFileSync(join(project, ".hyperframes", "app-history.jsonl"), '{"said":"secret"}'); + for (const path of [ + ".hyperframes/agent-handoff.json", + ".hyperframes/app-history.jsonl", + ".HyperFrames/Agent-Handoff.JSON", + ]) { + const res = await app.request(`/projects/p/files/${path}`); + expect([403, 404]).toContain(res.status); + expect(await res.text()).not.toContain("secret"); + } + const put = await app.request("/projects/p/files/.hyperframes/agent-handoff.json", { + method: "PUT", + body: "{}", + }); + expect(put.status).toBe(403); + const del = await app.request("/projects/p/files/.hyperframes/app-history.jsonl", { + method: "DELETE", + }); + expect(del.status).toBe(403); + linkOrSkip( + context, + join(project, ".hyperframes", "agent-handoff.json"), + join(project, "link.json"), + "file", + ); + const viaLink = await app.request("/projects/p/files/link.json"); + expect(await viaLink.text()).not.toContain("secret"); + }); + + it("leave the rest of .hyperframes/ to Studio", async () => { + const { app, project } = fixture(); + mkdirSync(join(project, ".hyperframes", "prepared-assets", "gif"), { recursive: true }); + writeFileSync( + join(project, ".hyperframes", "studio-motion.json"), + '{"version":1,"motions":[]}', + ); + writeFileSync(join(project, ".hyperframes", "prepared-assets", "gif", "a.mp4"), "mp4"); + for (const path of ["studio-motion.json", "prepared-assets/gif/a.mp4"]) + expect((await app.request(`/projects/p/files/.hyperframes/${path}`)).status).toBe(200); + }); +}); + +describe("routes that write a path from the request", () => { + it("never plant or copy a file into .hyperframes/", async () => { + const { app, project } = fixture(); + mkdirSync(join(project, ".hyperframes")); + writeFileSync( + join(project, ".hyperframes", "agent-handoff.json"), + '{"engine":"claude","sessionId":"secret"}', + ); + const rename = await app.request("/projects/p/files/inside.txt", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ newPath: ".hyperframes/app-history.jsonl" }), + }); + expect(rename.status).toBeGreaterThanOrEqual(400); + expect(existsSync(join(project, ".hyperframes", "app-history.jsonl"))).toBe(false); + const duplicate = await app.request("/projects/p/duplicate-file", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path: ".hyperframes/agent-handoff.json" }), + }); + expect(duplicate.status).toBeGreaterThanOrEqual(400); + expect(readdirSync(join(project, ".hyperframes"))).toEqual(["agent-handoff.json"]); + const form = new FormData(); + form.append("file", new File(['{"said":"t"}'], "app-history.jsonl")); + const upload = await app.request("/projects/p/upload?dir=.hyperframes", { + method: "POST", + body: form, + }); + expect((await upload.json()).files ?? []).toEqual([]); + expect(existsSync(join(project, ".hyperframes", "app-history.jsonl"))).toBe(false); + }); +}); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index 1c30b50a280..d9d85f8d87d 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -30,6 +30,7 @@ import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js"; import { folderGone, isInHiddenOrVendorDir, + isPrivateProjectFile, isSafePath, mkdirWithinProject, pinWithinProject, @@ -2481,7 +2482,7 @@ async function processUploadedFiles( // The collision suffix chooses a different path; validate that destination // too, including dangling symlinks that existsSync treats as absent. - if (!isSafePath(projectDir, finalPath)) continue; + if (!isSafePath(projectDir, finalPath) || isPrivateProjectFile(projectDir, finalPath)) continue; const buffer = Buffer.from(await value.arrayBuffer()); const validation = validateUploadedMediaBuffer(finalName, buffer); diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts index e8abd0afd9f..37a9189dcd7 100644 --- a/packages/studio-server/src/routes/preview.test.ts +++ b/packages/studio-server/src/routes/preview.test.ts @@ -2281,3 +2281,23 @@ describe("preview asset byte ranges", () => { expect(res.headers.get("Content-Range")).toBe("bytes */3"); }); }); + +describe("the desktop app's private folder", () => { + it("is served by neither the asset route nor the sub-composition route", async () => { + const projectDir = createProjectDir(); + mkdirSync(join(projectDir, ".hyperframes"), { recursive: true }); + writeFileSync( + join(projectDir, ".hyperframes", "agent-handoff.json"), + '{"engine":"claude","sessionId":"secret"}', + ); + const app = new Hono(); + registerPreviewRoutes(app, createAdapter(projectDir)); + for (const route of ["preview", "preview/comp"]) { + const res = await app.request( + `http://localhost/projects/demo/${route}/.hyperframes/agent-handoff.json`, + ); + expect(res.status).toBe(404); + expect(await res.text()).not.toContain("secret"); + } + }); +}); diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts index e0d7577e977..89719fa2622 100644 --- a/packages/studio-server/src/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -18,7 +18,11 @@ import { gsapCdnDist, motionPathPluginUrl } from "@hyperframes/core/gsap-cdn"; import { findStartTags, injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document"; import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution"; import type { ResolvedProject, StudioApiAdapter } from "../types.js"; -import { isProjectRootMissing, resolveWithinProject } from "../helpers/safePath.js"; +import { + isPrivateProjectFile, + isProjectRootMissing, + resolveWithinProject, +} from "../helpers/safePath.js"; import { getMimeType } from "../helpers/mime.js"; import { buildSubCompositionHtml, hasBaseElement } from "../helpers/subComposition.js"; import { @@ -615,7 +619,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo // use resolveWithinProject because saves write their data-hf-id values. const candidate = resolve(project.dir, subPath); const file = isWithinProjectRoot(project.dir, candidate) ? candidate : null; - if (!file) { + if (!file || isPrivateProjectFile(project.dir, file)) { return c.text("not found", 404); } recordPreviewRead(project.dir, file); From 23b76be728fdb31b924c3846608ec38a5ef26534 Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Tue, 6 Oct 2026 18:21:58 +0800 Subject: [PATCH 2/6] fix(studio-server): keep the desktop app's run log out of the file routes too The app records every chat run, the person's words included, in .hyperframes/agent-runs.jsonl. Only the app's main process reads it. --- packages/studio-server/src/helpers/safePath.test.ts | 3 ++- packages/studio-server/src/helpers/safePath.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/studio-server/src/helpers/safePath.test.ts b/packages/studio-server/src/helpers/safePath.test.ts index ca5abd8a0c2..37bbc79bb2b 100644 --- a/packages/studio-server/src/helpers/safePath.test.ts +++ b/packages/studio-server/src/helpers/safePath.test.ts @@ -84,9 +84,10 @@ describe("walkDir", () => { }); describe("isPrivateProjectFile", () => { - it("names the desktop app's hand-off and chat history, in any case, and nothing else", () => { + it("names the desktop app's hand-off and chat records, in any case, and nothing else", () => { expect(isPrivateProjectFile("/p", "/p/.hyperframes/agent-handoff.json")).toBe(true); expect(isPrivateProjectFile("/p", "/p/.HyperFrames/App-History.JSONL")).toBe(true); + expect(isPrivateProjectFile("/p", "/p/.hyperframes/agent-runs.jsonl")).toBe(true); expect(isPrivateProjectFile("/p", "/p/.hyperframes/studio-motion.json")).toBe(false); expect(isPrivateProjectFile("/p", "/p/.hyperframes/prepared-assets/gif/a.mp4")).toBe(false); expect(isPrivateProjectFile("/p", "/p/agent-handoff.json")).toBe(false); diff --git a/packages/studio-server/src/helpers/safePath.ts b/packages/studio-server/src/helpers/safePath.ts index f1fd7177072..098db3c4aba 100644 --- a/packages/studio-server/src/helpers/safePath.ts +++ b/packages/studio-server/src/helpers/safePath.ts @@ -30,9 +30,10 @@ export function realFilePath(filePath: string): string { } } -// The desktop app's hand-off and chat history: no route reads or writes them, by any spelling or through a link. +// The desktop app's hand-off and its records of the chat: no route reads or writes them, by any spelling or a link. const PRIVATE_FILES = new Set([ ".hyperframes/agent-handoff.json", + ".hyperframes/agent-runs.jsonl", ".hyperframes/app-history.jsonl", ]); From 51550e421fd920210173efbc9d4fb79c60764177 Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Tue, 6 Oct 2026 19:16:48 +0800 Subject: [PATCH 3/6] fix(studio-server): refuse everything under .hyperframes/ but Studio's own files Review (Jerrai, Rames): a list of three names missed the read marks the desktop app and the CLI also keep there, and renaming the folder reached them under a new name. The check is now an allow-list: no route reaches .hyperframes/ or anything in it, by any spelling or through a link, except Studio's studio-motion.json, studio-manual-edits.json and prepared-assets/. Studio's own internal files (backups, preview documents, history id) are read by path inside the server, not through a request path. The project's file tree leaves out the same paths. The upload test now sends a file uploads accept elsewhere, so the folder is the only reason it is refused. --- .../src/helpers/safePath.test.ts | 13 ++++++-- .../studio-server/src/helpers/safePath.ts | 25 ++++++++++----- .../src/routes/files.pathSafety.test.ts | 31 ++++++++++++++++--- .../studio-server/src/routes/projects.test.ts | 17 +++++----- packages/studio-server/src/routes/projects.ts | 4 +-- 5 files changed, 66 insertions(+), 24 deletions(-) diff --git a/packages/studio-server/src/helpers/safePath.test.ts b/packages/studio-server/src/helpers/safePath.test.ts index 37bbc79bb2b..d83cbdc242c 100644 --- a/packages/studio-server/src/helpers/safePath.test.ts +++ b/packages/studio-server/src/helpers/safePath.test.ts @@ -84,11 +84,18 @@ describe("walkDir", () => { }); describe("isPrivateProjectFile", () => { - it("names the desktop app's hand-off and chat records, in any case, and nothing else", () => { - expect(isPrivateProjectFile("/p", "/p/.hyperframes/agent-handoff.json")).toBe(true); + it("names everything under .hyperframes/ but Studio's own files, in any case", () => { + for (const name of [ + "agent-handoff.json", + "agent-handoff-read.json", + "app-history-seen.json", + "share.json", + ]) + expect(isPrivateProjectFile("/p", `/p/.hyperframes/${name}`)).toBe(true); expect(isPrivateProjectFile("/p", "/p/.HyperFrames/App-History.JSONL")).toBe(true); - expect(isPrivateProjectFile("/p", "/p/.hyperframes/agent-runs.jsonl")).toBe(true); + expect(isPrivateProjectFile("/p", "/p/.hyperframes")).toBe(true); expect(isPrivateProjectFile("/p", "/p/.hyperframes/studio-motion.json")).toBe(false); + expect(isPrivateProjectFile("/p", "/p/.hyperframes/studio-manual-edits.json")).toBe(false); expect(isPrivateProjectFile("/p", "/p/.hyperframes/prepared-assets/gif/a.mp4")).toBe(false); expect(isPrivateProjectFile("/p", "/p/agent-handoff.json")).toBe(false); }); diff --git a/packages/studio-server/src/helpers/safePath.ts b/packages/studio-server/src/helpers/safePath.ts index 098db3c4aba..29a853dc31a 100644 --- a/packages/studio-server/src/helpers/safePath.ts +++ b/packages/studio-server/src/helpers/safePath.ts @@ -30,17 +30,28 @@ export function realFilePath(filePath: string): string { } } -// The desktop app's hand-off and its records of the chat: no route reads or writes them, by any spelling or a link. -const PRIVATE_FILES = new Set([ - ".hyperframes/agent-handoff.json", - ".hyperframes/agent-runs.jsonl", - ".hyperframes/app-history.jsonl", +// `.hyperframes/` holds the desktop app's and the CLI's own records (a hand-off, chat history, read marks): no route +// reaches any of it, the folder itself included, but these files of Studio's own. +const STUDIO_FILES = new Set([ + ".hyperframes/studio-motion.json", + ".hyperframes/studio-manual-edits.json", ]); +const STUDIO_FOLDERS = [".hyperframes/prepared-assets/"]; + +/** For a project-relative path with `/` separators. */ +export function isPrivateProjectPath(relPath: string): boolean { + const path = relPath.toLowerCase(); + return ( + (path === ".hyperframes" || path.startsWith(".hyperframes/")) && + !STUDIO_FILES.has(path) && + !STUDIO_FOLDERS.some((folder) => path.startsWith(folder)) + ); +} export function isPrivateProjectFile(projectDir: string, filePath: string): boolean { - const rel = (from: string, to: string) => relative(from, to).split(sep).join("/").toLowerCase(); + const rel = (from: string, to: string) => relative(from, to).split(sep).join("/"); return [rel(projectDir, filePath), rel(realFilePath(projectDir), realFilePath(filePath))].some( - (path) => PRIVATE_FILES.has(path), + isPrivateProjectPath, ); } diff --git a/packages/studio-server/src/routes/files.pathSafety.test.ts b/packages/studio-server/src/routes/files.pathSafety.test.ts index dd9fb1dc79b..aa9b519df21 100644 --- a/packages/studio-server/src/routes/files.pathSafety.test.ts +++ b/packages/studio-server/src/routes/files.pathSafety.test.ts @@ -705,10 +705,15 @@ describe("the desktop app's private files", () => { '{"engine":"claude","sessionId":"secret"}', ); writeFileSync(join(project, ".hyperframes", "app-history.jsonl"), '{"said":"secret"}'); + writeFileSync( + join(project, ".hyperframes", "agent-handoff-read.json"), + '{"sessionId":"secret"}', + ); for (const path of [ ".hyperframes/agent-handoff.json", ".hyperframes/app-history.jsonl", ".HyperFrames/Agent-Handoff.JSON", + ".hyperframes/agent-handoff-read.json", ]) { const res = await app.request(`/projects/p/files/${path}`); expect([403, 404]).toContain(res.status); @@ -733,7 +738,20 @@ describe("the desktop app's private files", () => { expect(await viaLink.text()).not.toContain("secret"); }); - it("leave the rest of .hyperframes/ to Studio", async () => { + it("can't be reached by renaming their folder", async () => { + const { app, project } = fixture(); + mkdirSync(join(project, ".hyperframes")); + writeFileSync(join(project, ".hyperframes", "app-history.jsonl"), '{"said":"secret"}'); + const rename = await app.request("/projects/p/files/.hyperframes", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ newPath: "x" }), + }); + expect(rename.status).toBeGreaterThanOrEqual(400); + expect(existsSync(join(project, "x"))).toBe(false); + }); + + it("leave Studio's own files under .hyperframes/ reachable", async () => { const { app, project } = fixture(); mkdirSync(join(project, ".hyperframes", "prepared-assets", "gif"), { recursive: true }); writeFileSync( @@ -769,12 +787,17 @@ describe("routes that write a path from the request", () => { expect(duplicate.status).toBeGreaterThanOrEqual(400); expect(readdirSync(join(project, ".hyperframes"))).toEqual(["agent-handoff.json"]); const form = new FormData(); - form.append("file", new File(['{"said":"t"}'], "app-history.jsonl")); + // A file uploads accept anywhere else, so the folder is the only reason it is refused. + form.append("file", new File(["plain words"], "notes.txt")); const upload = await app.request("/projects/p/upload?dir=.hyperframes", { method: "POST", body: form, }); - expect((await upload.json()).files ?? []).toEqual([]); - expect(existsSync(join(project, ".hyperframes", "app-history.jsonl"))).toBe(false); + expect(upload.status).toBe(403); + expect(existsSync(join(project, ".hyperframes", "notes.txt"))).toBe(false); + const elsewhere = new FormData(); + elsewhere.append("file", new File(["plain words"], "notes.txt")); + const fine = await app.request("/projects/p/upload", { method: "POST", body: elsewhere }); + expect((await fine.json()).files).toEqual(["notes.txt"]); }); }); diff --git a/packages/studio-server/src/routes/projects.test.ts b/packages/studio-server/src/routes/projects.test.ts index 63804eb23ce..a484bc2649d 100644 --- a/packages/studio-server/src/routes/projects.test.ts +++ b/packages/studio-server/src/routes/projects.test.ts @@ -22,10 +22,9 @@ const COMPOSITION_HTML = '
{ expect(payload.compositions).not.toContain(".hyperframes/examples/preset.html"); }); - it("lists vendored dot-directory files in the file tree but hides Studio backups", async () => { + it("lists vendored dot-directory files in the file tree, but nothing under .hyperframes/ that isn't Studio's", async () => { const projectDir = createProjectDir(); + writeFileSync(join(projectDir, ".hyperframes", "app-history.jsonl"), "{}"); + writeFileSync(join(projectDir, ".hyperframes", "studio-motion.json"), "{}"); const app = new Hono(); registerProjectRoutes(app, createAdapter(projectDir)); @@ -159,9 +160,9 @@ describe("registerProjectRoutes — composition discovery (#1384)", () => { // Vendored dot-dirs stay browsable — discovery is gated, the file tree is not. expect(payload.files).toContain(".cache/examples/preset.html"); - expect(payload.files).toContain(".hyperframes/examples/preset.html"); - // Only Studio's own backup snapshots are hidden from the tree (#1366). - expect(payload.files).not.toContain(".hyperframes/backup/snapshot.html"); + expect(payload.files).toContain(".hyperframes/studio-motion.json"); + for (const hidden of ["examples/preset.html", "backup/snapshot.html", "app-history.jsonl"]) + expect(payload.files).not.toContain(`.hyperframes/${hidden}`); }); it("omits generated cache files from the project file tree", async () => { diff --git a/packages/studio-server/src/routes/projects.ts b/packages/studio-server/src/routes/projects.ts index cec4e418995..70e0af4b82c 100644 --- a/packages/studio-server/src/routes/projects.ts +++ b/packages/studio-server/src/routes/projects.ts @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import type { Hono } from "hono"; import type { StudioApiAdapter } from "../types.js"; -import { isInHiddenOrVendorDir, walkDir } from "../helpers/safePath.js"; +import { isInHiddenOrVendorDir, isPrivateProjectPath, walkDir } from "../helpers/safePath.js"; import { isCompositionSource } from "../helpers/hfIdPersist.js"; import { resolveProjectSignature } from "../helpers/projectSignature.js"; @@ -50,7 +50,7 @@ export function registerProjectRoutes(api: Hono, adapter: StudioApiAdapter): voi api.get("/projects/:id", async (c) => { const project = await adapter.resolveProject(c.req.param("id")); if (!project) return c.json({ error: "not found" }, 404); - const files = walkDir(project.dir); + const files = walkDir(project.dir).filter((file) => !isPrivateProjectPath(file)); const compositions = await filterCompositionFiles(project.dir, files); return c.json({ id: project.id, dir: project.dir, title: project.title, files, compositions }); }); From 31c20c53179a58077e7779dc754c1f29d92d100e Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Tue, 6 Oct 2026 20:07:34 +0800 Subject: [PATCH 4/6] fix(studio-server): serve the app's request pictures, close the renders and rename paths Review (Rames, Jerrai): - Desktop loads each request's picture from .hyperframes/requests/ through the preview route, so that folder joins the allow-list. An allowed folder's own path is allowed too. - The render-file route resolves against renders/, so a renders/ linked into .hyperframes/ reached the records. It now also checks the file against the project. - A rename's reference update walked every JSON/MD file, records included, and could rewrite them. It now skips private paths. - The allow-list reuses STUDIO_MOTION_PATH and STUDIO_MANUAL_EDITS_PATH. --- .../src/helpers/safePath.test.ts | 5 +++- .../studio-server/src/helpers/safePath.ts | 13 +++++----- .../src/routes/files.pathSafety.test.ts | 15 +++++++++++ packages/studio-server/src/routes/files.ts | 2 +- .../studio-server/src/routes/preview.test.ts | 11 ++++++++ .../studio-server/src/routes/render.test.ts | 26 +++++++++++++++++++ packages/studio-server/src/routes/render.ts | 10 +++++-- 7 files changed, 71 insertions(+), 11 deletions(-) diff --git a/packages/studio-server/src/helpers/safePath.test.ts b/packages/studio-server/src/helpers/safePath.test.ts index d83cbdc242c..3977babdf16 100644 --- a/packages/studio-server/src/helpers/safePath.test.ts +++ b/packages/studio-server/src/helpers/safePath.test.ts @@ -84,7 +84,7 @@ describe("walkDir", () => { }); describe("isPrivateProjectFile", () => { - it("names everything under .hyperframes/ but Studio's own files, in any case", () => { + it("names everything under .hyperframes/ but Studio's own files and request pictures, in any case", () => { for (const name of [ "agent-handoff.json", "agent-handoff-read.json", @@ -97,6 +97,9 @@ describe("isPrivateProjectFile", () => { expect(isPrivateProjectFile("/p", "/p/.hyperframes/studio-motion.json")).toBe(false); expect(isPrivateProjectFile("/p", "/p/.hyperframes/studio-manual-edits.json")).toBe(false); expect(isPrivateProjectFile("/p", "/p/.hyperframes/prepared-assets/gif/a.mp4")).toBe(false); + expect(isPrivateProjectFile("/p", "/p/.hyperframes/prepared-assets")).toBe(false); + expect(isPrivateProjectFile("/p", "/p/.hyperframes/requests/1/before.png")).toBe(false); + expect(isPrivateProjectFile("/p", "/p/.hyperframes/requests-old/x")).toBe(true); expect(isPrivateProjectFile("/p", "/p/agent-handoff.json")).toBe(false); }); }); diff --git a/packages/studio-server/src/helpers/safePath.ts b/packages/studio-server/src/helpers/safePath.ts index 29a853dc31a..500927d40b0 100644 --- a/packages/studio-server/src/helpers/safePath.ts +++ b/packages/studio-server/src/helpers/safePath.ts @@ -1,6 +1,8 @@ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; import { readdirSync, type Dirent } from "node:fs"; import { realpath, resolveWithinProject as resolveInProject } from "@hyperframes/core/safe-path"; +import { STUDIO_MANUAL_EDITS_PATH } from "./manualEditsRenderScript.js"; +import { STUDIO_MOTION_PATH } from "./studioMotionRenderScript.js"; // `isSafePath` lives at the package root so non-studio-api layers (compiler, // CLI, engine) can share it without a backwards dependency on studio-api. @@ -31,12 +33,9 @@ export function realFilePath(filePath: string): string { } // `.hyperframes/` holds the desktop app's and the CLI's own records (a hand-off, chat history, read marks): no route -// reaches any of it, the folder itself included, but these files of Studio's own. -const STUDIO_FILES = new Set([ - ".hyperframes/studio-motion.json", - ".hyperframes/studio-manual-edits.json", -]); -const STUDIO_FOLDERS = [".hyperframes/prepared-assets/"]; +// reaches any of it, the folder itself included, but Studio's own files and the app's request pictures. +const STUDIO_FILES = new Set([STUDIO_MOTION_PATH, STUDIO_MANUAL_EDITS_PATH]); +const STUDIO_FOLDERS = [".hyperframes/prepared-assets", ".hyperframes/requests"]; /** For a project-relative path with `/` separators. */ export function isPrivateProjectPath(relPath: string): boolean { @@ -44,7 +43,7 @@ export function isPrivateProjectPath(relPath: string): boolean { return ( (path === ".hyperframes" || path.startsWith(".hyperframes/")) && !STUDIO_FILES.has(path) && - !STUDIO_FOLDERS.some((folder) => path.startsWith(folder)) + !STUDIO_FOLDERS.some((folder) => path === folder || path.startsWith(`${folder}/`)) ); } diff --git a/packages/studio-server/src/routes/files.pathSafety.test.ts b/packages/studio-server/src/routes/files.pathSafety.test.ts index aa9b519df21..9f11493b2fe 100644 --- a/packages/studio-server/src/routes/files.pathSafety.test.ts +++ b/packages/studio-server/src/routes/files.pathSafety.test.ts @@ -751,6 +751,21 @@ describe("the desktop app's private files", () => { expect(existsSync(join(project, "x"))).toBe(false); }); + it("are neither read nor rewritten when a rename updates references", async () => { + const { app, project } = fixture(); + mkdirSync(join(project, ".hyperframes")); + const share = join(project, ".hyperframes", "share.json"); + writeFileSync(share, '{"ref":"inside.txt"}'); + const response = await app.request("/projects/p/files/inside.txt", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ newPath: "moved.txt" }), + }); + expect(response.status).toBe(200); + expect(readFileSync(share, "utf8")).toBe('{"ref":"inside.txt"}'); + expect((await response.json()).updatedReferences).toBe(0); + }); + it("leave Studio's own files under .hyperframes/ reachable", async () => { const { app, project } = fixture(); mkdirSync(join(project, ".hyperframes", "prepared-assets", "gif"), { recursive: true }); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index d9d85f8d87d..8d8083e0279 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -803,7 +803,7 @@ function updateReferences( const rewrite = referenceRewriter(oldPath, newPath, isDirectory, projectPaths(projectDir)); let updatedCount = 0; for (const file of textFiles) { - if (!isSafePath(projectDir, file)) continue; + if (!isSafePath(projectDir, file) || isPrivateProjectFile(projectDir, file)) continue; const content = readableText(file); if (content === null) continue; diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts index 37a9189dcd7..70b77113fe5 100644 --- a/packages/studio-server/src/routes/preview.test.ts +++ b/packages/studio-server/src/routes/preview.test.ts @@ -2300,4 +2300,15 @@ describe("the desktop app's private folder", () => { expect(await res.text()).not.toContain("secret"); } }); + it("still serves the app's request pictures", async () => { + const projectDir = createProjectDir(); + mkdirSync(join(projectDir, ".hyperframes", "requests", "1"), { recursive: true }); + writeFileSync(join(projectDir, ".hyperframes", "requests", "1", "before.png"), "png"); + const app = new Hono(); + registerPreviewRoutes(app, createAdapter(projectDir)); + const res = await app.request( + "http://localhost/projects/demo/preview/.hyperframes/requests/1/before.png", + ); + expect(res.status).toBe(200); + }); }); diff --git a/packages/studio-server/src/routes/render.test.ts b/packages/studio-server/src/routes/render.test.ts index e888951de17..38b4a1527f0 100644 --- a/packages/studio-server/src/routes/render.test.ts +++ b/packages/studio-server/src/routes/render.test.ts @@ -491,6 +491,32 @@ describe("GET /projects/:id/renders/file/* — path safety", () => { } }); + it("refuses the desktop app's records when renders/ is linked into .hyperframes/", async () => { + const project = mkdtempSync(join(tmpdir(), "hf-renders-project-")); + tmpDirs.push(project); + mkdirSync(join(project, ".hyperframes")); + writeFileSync(join(project, ".hyperframes", "app-history.jsonl"), '{"asked":"secret"}'); + if (!tryCreateSymlink(join(project, ".hyperframes"), join(project, "renders"), "dir")) return; + const app = new Hono(); + registerRenderRoutes(app, { + listProjects: () => [], + resolveProject: async (id: string) => ({ id, dir: project }), + bundle: async () => null, + lint: async () => ({ findings: [] }), + runtimeUrl: "/api/runtime.js", + rendersDir: () => join(project, "renders"), + startRender: (opts) => ({ + id: opts.jobId, + status: "rendering", + progress: 0, + outputPath: opts.outputPath, + }), + }); + const res = await app.request("http://localhost/projects/demo/renders/file/app-history.jsonl"); + expect(res.status).toBe(403); + expect(await res.text()).not.toContain("secret"); + }); + it("serves a render file that lives inside rendersDir", async () => { const { app, rendersDir } = buildApp(); writeFileSync(join(rendersDir, "demo.mp4"), "render-bytes"); diff --git a/packages/studio-server/src/routes/render.ts b/packages/studio-server/src/routes/render.ts index 9e312a97fc3..2c82e7f9936 100644 --- a/packages/studio-server/src/routes/render.ts +++ b/packages/studio-server/src/routes/render.ts @@ -5,7 +5,12 @@ import { join } from "node:path"; import type { StudioApiAdapter, RenderJobState } from "../types.js"; import { VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "@hyperframes/parsers"; import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core"; -import { folderGone, mkdirWithinProject, resolveWithinProject } from "../helpers/safePath.js"; +import { + folderGone, + isPrivateProjectFile, + mkdirWithinProject, + resolveWithinProject, +} from "../helpers/safePath.js"; import { projectDirMissing } from "../helpers/projectDirMissing.js"; import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js"; @@ -274,7 +279,8 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void // readFileSync still followed an in-rendersDir symlink pointing outside the // dir; resolveWithinProject canonicalizes with realpath before serving. const fp = resolveWithinProject(rendersDir, filename); - if (!fp) return c.json({ error: "forbidden" }, 403); + // The check above sees a path inside renders/; a renders/ linked into .hyperframes/ is caught against the project. + if (!fp || isPrivateProjectFile(project.dir, fp)) return c.json({ error: "forbidden" }, 403); if (!existsSync(fp)) return c.json({ error: "not found" }, 404); const contentType = renderContentType(fp); const content = readFileSync(fp); From 58a7a86edd11db5b96826e242c25a134c62a9f14 Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Tue, 6 Oct 2026 20:10:57 +0800 Subject: [PATCH 5/6] chore(studio-server): keep render.ts's comment share at the base --- packages/studio-server/src/routes/render.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/studio-server/src/routes/render.ts b/packages/studio-server/src/routes/render.ts index 2c82e7f9936..12d96242262 100644 --- a/packages/studio-server/src/routes/render.ts +++ b/packages/studio-server/src/routes/render.ts @@ -279,7 +279,6 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void // readFileSync still followed an in-rendersDir symlink pointing outside the // dir; resolveWithinProject canonicalizes with realpath before serving. const fp = resolveWithinProject(rendersDir, filename); - // The check above sees a path inside renders/; a renders/ linked into .hyperframes/ is caught against the project. if (!fp || isPrivateProjectFile(project.dir, fp)) return c.json({ error: "forbidden" }, 403); if (!existsSync(fp)) return c.json({ error: "not found" }, 404); const contentType = renderContentType(fp); From dc304cd975ddc2c778797ed2e7b08ed7f485d4ee Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Tue, 6 Oct 2026 20:40:43 +0800 Subject: [PATCH 6/6] fix(studio-server): list, view and download only renders the render-file route would serve Review (Jerrai, Rames): the render list found files in renders/ with join and statSync, which follow links, and registered them for /render/:id/view and /download, which read them with no check. A project shipping renders/leak.mp4 -> ../.hyperframes/app-history.jsonl had its chat served. The list now keeps an entry only when resolveWithinProject accepts it and it isn't private, and registers the resolved path. View and download serve only a plain file, so a link swapped in after the list is refused. This predates the PR; it's the same shipped-link case. --- .../studio-server/src/routes/render.test.ts | 36 +++++++++++++++++++ packages/studio-server/src/routes/render.ts | 26 ++++++++++---- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/studio-server/src/routes/render.test.ts b/packages/studio-server/src/routes/render.test.ts index 38b4a1527f0..303af447019 100644 --- a/packages/studio-server/src/routes/render.test.ts +++ b/packages/studio-server/src/routes/render.test.ts @@ -517,6 +517,42 @@ describe("GET /projects/:id/renders/file/* — path safety", () => { expect(await res.text()).not.toContain("secret"); }); + it("never lists, views or downloads a render that links to the app's records", async () => { + const project = mkdtempSync(join(tmpdir(), "hf-renders-project-")); + tmpDirs.push(project); + mkdirSync(join(project, ".hyperframes")); + mkdirSync(join(project, "renders")); + writeFileSync(join(project, ".hyperframes", "app-history.jsonl"), '{"asked":"secret"}'); + writeFileSync(join(project, "renders", "real.mp4"), "render-bytes"); + const leak = join(project, "renders", "leak.mp4"); + if (!tryCreateSymlink(join(project, ".hyperframes", "app-history.jsonl"), leak, "file")) return; + const app = new Hono(); + registerRenderRoutes(app, { + listProjects: () => [], + resolveProject: async (id: string) => ({ id, dir: project }), + bundle: async () => null, + lint: async () => ({ findings: [] }), + runtimeUrl: "/api/runtime.js", + rendersDir: () => join(project, "renders"), + startRender: (opts) => ({ + id: opts.jobId, + status: "rendering", + progress: 0, + outputPath: opts.outputPath, + }), + }); + const list = (await (await app.request("http://localhost/projects/demo/renders")).json()) as { + renders: { id: string }[]; + }; + expect(list.renders.map((r) => r.id)).toEqual(["real"]); + for (const kind of ["view", "download"]) { + const res = await app.request(`http://localhost/render/leak/${kind}`); + expect(res.status).toBe(404); + expect(await res.text()).not.toContain("secret"); + expect((await app.request(`http://localhost/render/real/${kind}`)).status).toBe(200); + } + }); + it("serves a render file that lives inside rendersDir", async () => { const { app, rendersDir } = buildApp(); writeFileSync(join(rendersDir, "demo.mp4"), "render-bytes"); diff --git a/packages/studio-server/src/routes/render.ts b/packages/studio-server/src/routes/render.ts index 12d96242262..7a885e48309 100644 --- a/packages/studio-server/src/routes/render.ts +++ b/packages/studio-server/src/routes/render.ts @@ -1,6 +1,6 @@ import type { Hono } from "hono"; import { streamSSE } from "hono/streaming"; -import { existsSync, readFileSync, unlinkSync, readdirSync, statSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync, unlinkSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import type { StudioApiAdapter, RenderJobState } from "../types.js"; import { VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "@hyperframes/parsers"; @@ -204,6 +204,14 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void }; const RENDER_EXTENSIONS = Object.keys(RENDER_MIME); + const isPlainFile = (path: string): boolean => { + try { + return lstatSync(path).isFile(); + } catch { + return false; + } + }; + function renderContentType(filePath: string): string { const ext = RENDER_EXTENSIONS.find((e) => filePath.endsWith(e)); return (ext && RENDER_MIME[ext]) ?? "video/mp4"; @@ -214,7 +222,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void api.get("/render/:jobId/view", (c) => { const { jobId } = c.req.param(); const job = renderJobs.get(jobId); - if (!job?.outputPath || !existsSync(job.outputPath)) { + if (!job?.outputPath || !isPlainFile(job.outputPath)) { return c.json({ error: "not found" }, 404); } const contentType = renderContentType(job.outputPath); @@ -235,7 +243,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void api.get("/render/:jobId/download", (c) => { const { jobId } = c.req.param(); const job = renderJobs.get(jobId); - if (!job?.outputPath || !existsSync(job.outputPath)) { + if (!job?.outputPath || !isPlainFile(job.outputPath)) { return c.json({ error: "not found" }, 404); } const contentType = renderContentType(job.outputPath); @@ -301,8 +309,11 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void if (!existsSync(rendersDir)) return c.json({ renders: [] }); const files = readdirSync(rendersDir) .filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov")) - .map((f) => { - const fp = join(rendersDir, f); + .flatMap((f) => { + const fp = resolveWithinProject(rendersDir, f); + return fp && !isPrivateProjectFile(project.dir, fp) ? [{ f, fp }] : []; + }) + .map(({ f, fp }) => { const stat = statSync(fp); const rid = f.replace(/\.(mp4|webm|mov)$/, ""); const metaPath = join(rendersDir, `${rid}.meta.json`); @@ -326,6 +337,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void return { id: rid, filename: f, + path: fp, size: stat.size, createdAt: stat.mtimeMs, status, @@ -342,11 +354,11 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void id: file.id, status: file.status, progress: 100, - outputPath: join(rendersDir, file.filename), + outputPath: file.path, createdAt: file.createdAt, } as RenderJobState & { createdAt: number }); } } - return c.json({ renders: files }); + return c.json({ renders: files.map(({ path: _path, ...render }) => render) }); }); }