From 8877f90046ceb7991b243453039a3df37d1b22d3 Mon Sep 17 00:00:00 2001 From: probe Date: Fri, 23 Oct 2026 16:05:15 +0800 Subject: [PATCH] fix(webui): keep workspace writes inside the workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What was broken: the upstream runtime's workspace write path (`resolveWorkspaceWritePath` in `packages/local-runtime-v2/src/service/workspace/operations/workspace-path.ts`) falls back to the lexical spelling when `realpath` fails. A dangling symlink fails `realpath` exactly as a file that is not there yet does, but a write through a dangling link creates the file it points at — so the write landed outside the workspace while the check compared a path inside it. The upstream fix for this was merged and then reverted (#240), because it touched packages this branch does not own. Why this fix: the same fence is reimplemented on the WebUI side, where the binding that forwards a workspace write is ours. `packages/webui/src/runtime/workspace-real-path.ts` resolves a path to the location the filesystem really uses for it — following symlinks, and following dangling ones by hand — and compares it against the real workspace root. A path that cannot be resolved at all (a symlink cycle, a permission error, a missing root) is refused rather than guessed at, and a symlink that stays inside the workspace keeps working. The fence is wired into the two WebUI bindings that write into a workspace: `applyCanvas`, whose operation names workspace files, and `extractWorkspaceArchive`, whose destination is created before anything is written into it. It validates only: the path reaches the runtime exactly as it was spelled, and the refusal is reported in the WebUI's own voice. How it was verified: with real temp directories and real symlinks. A dangling symlink pointing outside the workspace is refused and the file it points at is never created; a symlink pointing inside the workspace keeps working; a workspace root that is itself a symlink keeps working; an ordinary path is accepted; a symlink cycle is refused instead of followed. The harness half of the suite proves the runtime is never called for a path that leaves the workspace — both refusals fail without the fence. Known boundaries: the upstream `realpathOrFallback` is still live, so a write that does not pass through a WebUI binding is still exposed there. The fence cannot close the gap between its `realpath` and the write itself, so a symlink swapped in between the two is still a race. A shell run in the workspace is out of scope. --- .../webui/src/runtime/harness/workspace.ts | 103 +++++- .../webui/src/runtime/workspace-real-path.ts | 147 ++++++++ .../gates/fixtures/path-metadata-f00.json | 9 + .../test/unit/workspace-write-fence.test.ts | 332 ++++++++++++++++++ release/public-source.json | 2 + test/vitest-suites.json | 3 +- 6 files changed, 593 insertions(+), 3 deletions(-) create mode 100644 packages/webui/src/runtime/workspace-real-path.ts create mode 100644 packages/webui/test/unit/workspace-write-fence.test.ts diff --git a/packages/webui/src/runtime/harness/workspace.ts b/packages/webui/src/runtime/harness/workspace.ts index 7673a2345..d03111f4f 100644 --- a/packages/webui/src/runtime/harness/workspace.ts +++ b/packages/webui/src/runtime/harness/workspace.ts @@ -11,10 +11,12 @@ import { extractWorkspaceArchiveDirectory, readWorkspaceArchiveListing, } from "../workspace-archive.js"; +import { checkWorkspaceFence } from "../workspace-real-path.js"; import type { WebuiHarnessPort } from "../port.js"; -import type { WebuiRuntimeHostHandle } from "./host-contract.js"; +import type { WebuiRuntimeCliService, WebuiRuntimeHostHandle } from "./host-contract.js"; import { requireCliService, requireHostSlot } from "./requirements.js"; import { toRuntimeCanvasInput } from "./canvas-input.js"; +import { WebuiErrorCode, WebuiWireError } from "../../shared/envelope.js"; import { toWebuiCanvasApplyResult, toWebuiCanvasDocument, @@ -99,6 +101,14 @@ export function createWorkspaceAdapter( async extractWorkspaceArchive(request) { const cliService = requireCliService(host); + // The destination is where the entries are written, so it is fenced + // before anything is created: a destination that leaves the workspace + // through a symlink would otherwise create its directory outside first + // and be refused afterwards. + await assertArchiveDestinationInsideWorkspace( + request.workspaceDir, + request.destination, + ); if (cliService.extractWorkspaceArchive) return cliService.extractWorkspaceArchive(request); return extractWorkspaceArchiveDirectory(request); }, @@ -246,12 +256,101 @@ export function createWorkspaceAdapter( async applyCanvas(request) { const cliService = requireCliService(host); + const input = toRuntimeCanvasInput(request); + // Fenced before the runtime is asked to apply the operation: the canvas + // names workspace files, and a name that leaves the workspace through a + // symlink has to be refused here rather than after the runtime resolved + // it. The path itself is passed on unchanged — the fence only decides. + await assertCanvasPathsInsideWorkspace(cliService, input); return toWebuiCanvasApplyResult( await requireHostSlot(cliService.applyCanvas, "canvas writes").call( cliService, - toRuntimeCanvasInput(request), + input, ), ); }, }; } + +/** + * The archive destination has to really live inside the workspace. + * + * `destination` is a path the browser chose, and the extraction creates it + * before it writes anything into it. The fence therefore runs first: a + * destination that leaves the workspace through a symlink is refused before the + * directory exists, not after. + * + * The wording is the archive reader's own — the panel shows this message next + * to the extraction it refused. + */ +async function assertArchiveDestinationInsideWorkspace( + workspaceDir: string, + destination: string, +): Promise { + const verdict = await checkWorkspaceFence( + workspaceDir, + path.resolve(workspaceDir, destination), + ); + if (verdict.allowed) return; + throw new WebuiWireError( + WebuiErrorCode.invalidBody, + verdict.reason === "outside" + ? `解压目标越界:${destination}` + : `解压目标无法解析:${destination}`, + ); +} + +/** + * Every workspace file the canvas operation names has to really live inside the + * session's workspace. + * + * The canvas carries workspace *relative* names, and the root they are relative + * to is the session's — the runtime resolves the same root on its side, so the + * fence asks the same question of the same source. A mutation that names no + * file (an asset-backed one, whose path the runtime picks) is not fenced: there + * is no client-chosen path to check. + */ +async function assertCanvasPathsInsideWorkspace( + cliService: WebuiRuntimeCliService, + input: ReturnType, +): Promise { + const workspaceDir = await resolveSessionWorkspaceDir(cliService, input.sessionId); + // Without a workspace there is no root to compare against. The runtime + // refuses such a canvas itself ("Session workspace is unavailable"), so no + // write can follow from this. + if (!workspaceDir) return; + for (const mutation of input.operation.mutations) { + if (mutation.kind !== "add_file") continue; + const relativePath = mutation.relativePath; + if (relativePath === undefined) continue; + const verdict = await checkWorkspaceFence( + workspaceDir, + path.resolve(workspaceDir, relativePath), + ); + if (verdict.allowed) continue; + throw new WebuiWireError( + WebuiErrorCode.invalidBody, + verdict.reason === "outside" + ? `Canvas file path escapes the workspace: ${relativePath}` + : `Canvas file path cannot be resolved: ${relativePath}`, + ); + } +} + +/** + * The workspace the session works in, as the runtime reports it. + * + * `undefined` when the host cannot answer: the session lookup is a required + * member of the host contract, but a host that does not implement it leaves the + * canvas root unknown, and the runtime then resolves the canvas path against + * the session's own workspace and refuses a path that is not inside it. + */ +async function resolveSessionWorkspaceDir( + cliService: WebuiRuntimeCliService, + sessionId: string, +): Promise { + if (typeof cliService.getSession !== "function") return undefined; + const found = await cliService.getSession({ id: sessionId }, {}); + const workspaceDir = found.session?.workspaceDir?.trim(); + return workspaceDir ? workspaceDir : undefined; +} diff --git a/packages/webui/src/runtime/workspace-real-path.ts b/packages/webui/src/runtime/workspace-real-path.ts new file mode 100644 index 000000000..4d0925d72 --- /dev/null +++ b/packages/webui/src/runtime/workspace-real-path.ts @@ -0,0 +1,147 @@ +// The real location of a workspace path, and the fence that keeps a write +// inside the workspace. +// +// The workspace boundary is checked on the *real* path, never on the spelling a +// request carries. A path that looks like it is inside the workspace can still +// point outside it: a symlink inside the workspace does, and a *dangling* +// symlink does so in the way that matters for a write — `realpath` fails on it +// exactly as it fails on a file that is not there yet, but writing through the +// link creates the file it points at. A check that falls back to the lexical +// spelling in that case compares a path inside the workspace and lets the file +// land outside it. That fallback is what the upstream runtime's write path +// still does (`resolveWorkspaceWritePath` in +// `packages/local-runtime-v2/src/service/workspace/operations/workspace-path.ts`), +// and the upstream fix for it was reverted because it touched packages this +// branch does not own. This is the same fence on this side of the boundary. +// +// Two rules the walk keeps: +// * A path that cannot be resolved at all — a symlink cycle, a permission +// error, a missing workspace root — is refused rather than guessed at. +// * A symlink that stays inside the workspace keeps working: the fence +// compares real locations, so an in-workspace link is inside by definition. +// +// Node built-ins only; the fence adds no dependency to this package. + +import { readlink, realpath } from "node:fs/promises"; +import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; + +/** Symlink hops before the walk gives up (guards against cycles). */ +const MAX_REAL_PATH_SYMLINK_HOPS = 32; + +/** + * What the fence says about one target path. + * + * `unresolvable` and `outside` are separate answers because they are separate + * facts: the first is a path the filesystem could not explain, the second is a + * path that is explained and does not belong to this workspace. Both are + * refusals, and both are reported, but the caller phrases them differently. + */ +export type WorkspaceFenceVerdict = + | { readonly allowed: true } + | { readonly allowed: false; readonly reason: "outside" | "unresolvable" }; + +/** + * Resolve a path to the location the filesystem really uses for it. + * + * `realpath` the target; when the target does not exist yet — a write creates a + * file that is not there — resolve its nearest existing ancestor and re-attach + * the missing segments. + * + * The part that needs a hand: the target may be a *dangling* symlink. `realpath` + * fails on it the same way it fails on a missing file, but a write through a + * dangling link creates the file it points at, so the link is followed here + * instead of being treated as a missing path. Without that branch a dangling + * link that points outside the workspace resolves to a path *inside* it and + * passes the fence. + * + * Returns `undefined` when the path cannot be resolved at all — a symlink + * cycle, a permission error — so the caller refuses the target instead of + * guessing where it points. + */ +export async function resolveRealPath( + absolutePath: string, + hops = 0, +): Promise { + let candidate = resolve(absolutePath); + const suffixParts: string[] = []; + for (;;) { + try { + const ancestorReal = await realpath(candidate); + return suffixParts.length === 0 + ? ancestorReal + : resolve(ancestorReal, ...suffixParts); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code !== "ENOENT" && code !== "ENOTDIR") return undefined; + const link = await readlinkOrUndefined(candidate); + if (link !== undefined) { + if (hops >= MAX_REAL_PATH_SYMLINK_HOPS) return undefined; + const realParent = await realpathOrUndefined(dirname(candidate)); + if (!realParent) return undefined; + // A relative link is relative to the directory that holds it, which + // is the *real* directory when the link sits in a symlinked one. + const followed = isAbsolute(link) ? resolve(link) : resolve(realParent, link); + const resolved = await resolveRealPath(followed, hops + 1); + if (!resolved) return undefined; + return suffixParts.length ? resolve(resolved, ...suffixParts) : resolved; + } + const parent = dirname(candidate); + if (parent === candidate) return undefined; + suffixParts.unshift(basename(candidate)); + candidate = parent; + } + } +} + +/** + * Whether `candidate` is `root` itself or a path inside it. + * + * The comparison is `path.relative`-based rather than a string prefix: a prefix + * wrongly accepts `/tmp/evil` for a root of `/tmp/ev`, and a sibling named + * `..evil` is not an escape either. + */ +export function isInsideDirectory(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return !(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)); +} + +/** + * The fence: does `target` really live inside `workspaceRoot`? + * + * The root is resolved with `realpath` as well — a workspace root is itself + * allowed to be a symlink, and comparing a real target against a lexical root + * would refuse every path in such a workspace. + * + * Refuses rather than guesses: a target that cannot be resolved, and a + * workspace root that cannot be resolved, are both `unresolvable`. + */ +export async function checkWorkspaceFence( + workspaceRoot: string, + target: string, +): Promise { + const realRoot = await realpathOrUndefined(resolve(workspaceRoot)); + if (!realRoot) return { allowed: false, reason: "unresolvable" }; + const realTarget = await resolveRealPath(target); + if (!realTarget) return { allowed: false, reason: "unresolvable" }; + return isInsideDirectory(realRoot, realTarget) + ? { allowed: true } + : { allowed: false, reason: "outside" }; +} + +export async function realpathOrUndefined( + target: string, +): Promise { + try { + return await realpath(target); + } catch { + return undefined; + } +} + +async function readlinkOrUndefined(target: string): Promise { + try { + return await readlink(target); + } catch { + return undefined; + } +} diff --git a/packages/webui/test/gates/fixtures/path-metadata-f00.json b/packages/webui/test/gates/fixtures/path-metadata-f00.json index 9dda44f9c..48730b4bc 100644 --- a/packages/webui/test/gates/fixtures/path-metadata-f00.json +++ b/packages/webui/test/gates/fixtures/path-metadata-f00.json @@ -3909,6 +3909,15 @@ "business": null, "home": "outside" }, + { + "path": "runtime/workspace-real-path.ts", + "layers": [ + "runtime" + ], + "role": "outside", + "business": null, + "home": "outside" + }, { "path": "server/access-policy.ts", "layers": [ diff --git a/packages/webui/test/unit/workspace-write-fence.test.ts b/packages/webui/test/unit/workspace-write-fence.test.ts new file mode 100644 index 000000000..76854165a --- /dev/null +++ b/packages/webui/test/unit/workspace-write-fence.test.ts @@ -0,0 +1,332 @@ +// The real-path fence that keeps a workspace write inside the workspace. +// +// The clauses these pin shut: +// * a dangling symlink that points outside the workspace is refused, and the +// file it points at is never created; +// * a symlink that points inside the workspace keeps working — the fence +// compares real locations, so an in-workspace link is inside by +// definition; +// * a workspace root that is itself a symlink still works; +// * an ordinary path inside the workspace is accepted; +// * a symlink cycle is refused rather than followed, and never loops. +// +// The fixtures are real directories and real symlinks in the OS temp dir: the +// bug this fence closes is about what the filesystem does with a link, so a +// mocked one would assert nothing. +// +// The harness half of the file proves the fence runs *before* the runtime is +// asked to write: the host is never called for a path that leaves the +// workspace, so the file it would have created outside cannot appear. + +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createHarnessPortFromHost } from "../../src/runtime/harness/adapter.js"; +import { checkWorkspaceFence } from "../../src/runtime/workspace-real-path.js"; + +/** Fixtures live in the OS temp dir; nothing is written into the repo tree. */ +let root = ""; +let workspace = ""; + +const layout = { x: 0, y: 0, width: 1, height: 1, zIndex: 0 }; + +/** + * The write the fence guards. + * + * `writeFile` on the lexical path is what the runtime's workspace writer does: + * it opens the path as spelled, and the filesystem follows a symlink on the + * way — creating the file a dangling link points at. The fence decides first; + * the write itself is unchanged. + */ +async function writeWorkspaceFile( + workspaceRoot: string, + relativePath: string, + content: string, +): Promise { + const target = path.resolve(workspaceRoot, relativePath); + const verdict = await checkWorkspaceFence(workspaceRoot, target); + if (!verdict.allowed) { + throw new Error( + verdict.reason === "outside" + ? `path escapes the workspace: ${relativePath}` + : `path cannot be resolved: ${relativePath}`, + ); + } + await writeFile(target, content, "utf8"); +} + +beforeAll(() => { + // `realpathSync` first: on macOS the temp dir is a symlink, and the fence + // resolves the workspace root through `realpath`, so an unresolved fixture + // path would not match what the fence compares against. + root = mkdtempSync(path.join(realpathSync(tmpdir()), "webui-write-fence-")); + workspace = path.join(root, "workspace"); + mkdirSync(workspace); +}); + +afterAll(() => { + if (root) rmSync(root, { force: true, recursive: true }); +}); + +describe("the workspace write fence", () => { + it("refuses a dangling symlink that points outside the workspace, and writes nothing outside it", async () => { + const outside = path.join(root, "outside"); + mkdirSync(outside, { recursive: true }); + const planted = path.join(outside, "planted.txt"); + symlinkSync(planted, path.join(workspace, "escape")); + + // The escape is real without the fence: writing through the dangling link + // creates the file it points at, outside the workspace. + await writeFile(path.join(workspace, "escape"), "planted", "utf8"); + expect(existsSync(planted)).toBe(true); + rmSync(planted); + + await expect( + checkWorkspaceFence(workspace, path.join(workspace, "escape")), + ).resolves.toEqual({ allowed: false, reason: "outside" }); + + await expect( + writeWorkspaceFile(workspace, "escape", "planted"), + ).rejects.toThrow("path escapes the workspace: escape"); + expect(existsSync(planted)).toBe(false); + }); + + it("keeps a symlink that points inside the workspace working", async () => { + writeFileSync(path.join(workspace, "real.txt"), "real", "utf8"); + symlinkSync( + path.join(workspace, "real.txt"), + path.join(workspace, "inside-link"), + ); + + await expect( + checkWorkspaceFence(workspace, path.join(workspace, "inside-link")), + ).resolves.toEqual({ allowed: true }); + + await writeWorkspaceFile(workspace, "inside-link", "written"); + expect(readFileSync(path.join(workspace, "real.txt"), "utf8")).toBe("written"); + }); + + it("accepts a workspace root that is itself a symlink", async () => { + const realRoot = path.join(root, "real-root"); + mkdirSync(realRoot, { recursive: true }); + const linkedRoot = path.join(root, "linked-root"); + symlinkSync(realRoot, linkedRoot); + + await expect( + checkWorkspaceFence(linkedRoot, path.join(linkedRoot, "note.txt")), + ).resolves.toEqual({ allowed: true }); + + await writeWorkspaceFile(linkedRoot, "note.txt", "hello"); + expect(readFileSync(path.join(realRoot, "note.txt"), "utf8")).toBe("hello"); + }); + + it("accepts an ordinary path inside the workspace", async () => { + await expect( + checkWorkspaceFence(workspace, path.join(workspace, "plain.txt")), + ).resolves.toEqual({ allowed: true }); + + await writeWorkspaceFile(workspace, "plain.txt", "plain"); + expect(readFileSync(path.join(workspace, "plain.txt"), "utf8")).toBe("plain"); + }); + + it("refuses a symlink cycle instead of following it", async () => { + symlinkSync(path.join(workspace, "cycle-b"), path.join(workspace, "cycle-a")); + symlinkSync(path.join(workspace, "cycle-a"), path.join(workspace, "cycle-b")); + + await expect( + checkWorkspaceFence(workspace, path.join(workspace, "cycle-a")), + ).resolves.toEqual({ allowed: false, reason: "unresolvable" }); + }); + + it("refuses a workspace root it cannot resolve", async () => { + await expect( + checkWorkspaceFence( + path.join(root, "missing-root"), + path.join(root, "missing-root", "file.txt"), + ), + ).resolves.toEqual({ allowed: false, reason: "unresolvable" }); + }); +}); + +describe("the harness fences the workspace writes it forwards", () => { + /** A host that writes the canvas path the way the runtime's writer does. */ + function canvasHost(workspaceDir: string) { + const applyCanvas = vi.fn( + async (input: { readonly operation: { readonly mutations: readonly { readonly kind: string; readonly relativePath?: string }[] } }) => { + for (const mutation of input.operation.mutations) { + if (mutation.kind !== "add_file" || mutation.relativePath === undefined) + continue; + await writeFile( + path.resolve(workspaceDir, mutation.relativePath), + "written by the host", + "utf8", + ); + } + return { + operationId: "op", + document: { + schemaVersion: 1 as const, + canvasId: "canvas", + sessionId: "s1", + changeSeq: 1, + nodes: [], + updatedAtMs: 1, + }, + }; + }, + ); + const getSession = vi.fn(async () => ({ + session: { sessionId: "s1", workspaceDir }, + })); + return { applyCanvas, getSession }; + } + + it("refuses a canvas path that leaves the workspace before the runtime is called", async () => { + const outside = path.join(root, "canvas-outside"); + mkdirSync(outside, { recursive: true }); + const planted = path.join(outside, "planted.txt"); + symlinkSync(planted, path.join(workspace, "canvas-escape")); + + const { applyCanvas, getSession } = canvasHost(workspace); + const port = createHarnessPortFromHost({ + apiHost: { close: async () => {} }, + cliService: { applyCanvas, getSession } as never, + }); + + await expect( + port.applyCanvas({ + sessionId: "s1", + operation: { + schemaVersion: 1, + operationId: "op", + mutations: [ + { kind: "add_file", nodeId: "n1", relativePath: "canvas-escape", layout }, + ], + }, + }), + ).rejects.toMatchObject({ + code: "invalid_body", + message: "Canvas file path escapes the workspace: canvas-escape", + }); + expect(applyCanvas).not.toHaveBeenCalled(); + expect(existsSync(planted)).toBe(false); + }); + + it("forwards a canvas path that stays inside the workspace unchanged", async () => { + const { applyCanvas, getSession } = canvasHost(workspace); + const port = createHarnessPortFromHost({ + apiHost: { close: async () => {} }, + cliService: { applyCanvas, getSession } as never, + }); + + await port.applyCanvas({ + sessionId: "s1", + operation: { + schemaVersion: 1, + operationId: "op", + mutations: [ + { kind: "add_file", nodeId: "n1", relativePath: "plain.txt", layout }, + ], + }, + }); + expect(applyCanvas).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ sessionId: "s1" }), + ); + expect(readFileSync(path.join(workspace, "plain.txt"), "utf8")).toBe( + "written by the host", + ); + }); + + it("refuses an archive destination that leaves the workspace, and creates nothing outside it", async () => { + const outside = path.join(root, "archive-outside"); + mkdirSync(outside, { recursive: true }); + symlinkSync(outside, path.join(workspace, "archive-escape")); + + // The host creates the destination the way the archive reader does: + // `mkdir -p` on the requested path, which follows a symlinked ancestor. + const extractWorkspaceArchive = vi.fn(async (request: { readonly workspaceDir: string; readonly destination: string }) => { + await mkdir(path.resolve(request.workspaceDir, request.destination), { + recursive: true, + }); + return { archivePath: request.destination, destination: request.destination, writtenFiles: [] }; + }); + const port = createHarnessPortFromHost({ + apiHost: { close: async () => {} }, + cliService: { extractWorkspaceArchive } as never, + }); + + // The destination names a directory *under* the symlink: creating it with + // `mkdir -p` would create it outside the workspace, so the fence has to + // run before the extraction creates anything. + await expect( + port.extractWorkspaceArchive({ + workspaceDir: workspace, + path: "bundle.zip", + destination: "archive-escape/extracted", + }), + ).rejects.toMatchObject({ + code: "invalid_body", + message: "解压目标越界:archive-escape/extracted", + }); + expect(extractWorkspaceArchive).not.toHaveBeenCalled(); + expect(existsSync(path.join(outside, "extracted"))).toBe(false); + }); + + it("refuses an archive destination that is itself a symlink out of the workspace", async () => { + const outside = path.join(root, "archive-outside-direct"); + mkdirSync(outside, { recursive: true }); + symlinkSync(outside, path.join(workspace, "archive-escape-direct")); + + const extractWorkspaceArchive = vi.fn(); + const port = createHarnessPortFromHost({ + apiHost: { close: async () => {} }, + cliService: { extractWorkspaceArchive } as never, + }); + + await expect( + port.extractWorkspaceArchive({ + workspaceDir: workspace, + path: "bundle.zip", + destination: "archive-escape-direct", + }), + ).rejects.toMatchObject({ + code: "invalid_body", + message: "解压目标越界:archive-escape-direct", + }); + expect(extractWorkspaceArchive).not.toHaveBeenCalled(); + }); + + it("forwards an archive destination that stays inside the workspace", async () => { + const extractWorkspaceArchive = vi.fn(async () => ({ + archivePath: "bundle.zip", + destination: "out", + writtenFiles: [], + })); + const port = createHarnessPortFromHost({ + apiHost: { close: async () => {} }, + cliService: { extractWorkspaceArchive } as never, + }); + + await port.extractWorkspaceArchive({ + workspaceDir: workspace, + path: "bundle.zip", + destination: "out", + }); + expect(extractWorkspaceArchive).toHaveBeenCalledExactlyOnceWith({ + workspaceDir: workspace, + path: "bundle.zip", + destination: "out", + }); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 75a9d10fb..70a83c790 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -4106,6 +4106,7 @@ "packages/webui/src/runtime/workspace-archive/archive-types.ts", "packages/webui/src/runtime/workspace-archive/tar-adapter.ts", "packages/webui/src/runtime/workspace-archive/zip-adapter.ts", + "packages/webui/src/runtime/workspace-real-path.ts", "packages/webui/src/server/access-policy.ts", "packages/webui/src/server/browser-bridge/admission.ts", "packages/webui/src/server/browser-bridge/core-transport.ts", @@ -4469,6 +4470,7 @@ "packages/webui/test/unit/workspace-review-owner.test.ts", "packages/webui/test/unit/workspace-tab-registry.test.ts", "packages/webui/test/unit/workspace-tab-strip.test.tsx", + "packages/webui/test/unit/workspace-write-fence.test.ts", "packages/webui/test/unit/worktree-panel.test.tsx", "packages/webui/test/unit/worktree-state.test.ts", "packages/webui/tsconfig.client.json", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 58ab36dba..91abfa375 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -466,7 +466,8 @@ "packages/webui/test/unit/browser-session-release.test.ts", "packages/webui/test/unit/browser-bridge-cdp.test.ts", "packages/webui/test/unit/browser-bridge-adversarial.test.ts", - "packages/webui/test/unit/browser-bridge-credential-secrecy.test.ts" + "packages/webui/test/unit/browser-bridge-credential-secrecy.test.ts", + "packages/webui/test/unit/workspace-write-fence.test.ts" ] } }