diff --git a/apps/web-ui/packages/api/workspace-files/src/index.ts b/apps/web-ui/packages/api/workspace-files/src/index.ts index 4bad89320..c59e525a0 100644 --- a/apps/web-ui/packages/api/workspace-files/src/index.ts +++ b/apps/web-ui/packages/api/workspace-files/src/index.ts @@ -6,7 +6,9 @@ * File reads follow the composed filesystem's read access, including paths * outside the workspace. The selected Session header supplies the base for * relative paths, with the sandbox policy root as its no-cwd fallback, not a - * read-containment restriction. Directory listings and change observations + * read-containment restriction. Requested paths may carry the POSIX-style + * drive prefix (`/D:/…`) harness-written links use; it is canonicalized away + * before resolution. Directory listings and change observations * remain workspace-scoped. File-kind checks and configured read caps apply to * every preview; this service exposes no mutations. * @@ -112,6 +114,17 @@ function integerAtLeast(value: number, min: number, name: string): number { return value; } +/** + * Drop the POSIX-style drive prefix (`/D:/…`) harness-written links carry. + * Windows resolution would otherwise read the prefix as a `D:` directory under + * the workspace root and miss the target; the Desktop renderer applies the + * same canonicalization to such links before opening them. A directory named + * `D:` is legal on POSIX, so the prefix is preserved there. + */ +function stripPosixDrivePrefix(path: string): string { + return process.platform === "win32" ? path.replace(/^\/(?=[A-Za-z]:[/\\])/u, "") : path; +} + /** * Cut lines `offset` through `offset + limit - 1` from decoded chunks, stopping * at the first character past the page so the rest of the file is never read. @@ -260,6 +273,7 @@ export class WorkspaceFiles extends TypertRemoteService { range: WorkspaceFileRange, signal: AbortSignal, ): Promise { + path = stripPosixDrivePrefix(path); const { offset, limit } = this.resolvePage(range); const { target, info } = await this.locateFile(workspaceFileScope, path, signal); const page = await this.cutPage(target, offset, limit, signal, path); @@ -290,12 +304,15 @@ export class WorkspaceFiles extends TypertRemoteService { options: WorkspaceByteReadOptions, signal: AbortSignal, ): Promise { + path = stripPosixDrivePrefix(path); const window = options.range === undefined ? undefined : this.resolveWindow(options.range, path); + const baseFile = + options.baseFile === undefined ? undefined : stripPosixDrivePrefix(options.baseFile); const resolved = - options.baseFile === undefined + baseFile === undefined ? path - : await this.relativePath(workspaceFileScope, options.baseFile, path, signal); + : await this.relativePath(workspaceFileScope, baseFile, path, signal); const { target, info } = await this.locateFile(workspaceFileScope, resolved, signal); if (window !== undefined) { const { offset, length } = window; @@ -337,6 +354,7 @@ export class WorkspaceFiles extends TypertRemoteService { path: string, signal: AbortSignal, ): Promise { + path = stripPosixDrivePrefix(path); const { target, info } = await this.locateFile(workspaceFileScope, path, signal); return this.statOf(target, info); } @@ -354,6 +372,7 @@ export class WorkspaceFiles extends TypertRemoteService { path: string, signal: AbortSignal, ): Promise { + path = stripPosixDrivePrefix(path); const { root, workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal); // A final link — a Windows junction or a symlink — is listed through the // directory it resolves to, matching the child type `listDir` reports for @@ -398,6 +417,7 @@ export class WorkspaceFiles extends TypertRemoteService { path: string, signal: AbortSignal, ): AsyncIterable { + path = stripPosixDrivePrefix(path); return this.feed.follow(workspaceFileScope.workspaceRoot, path, signal); } diff --git a/packages/web-server/src/web-images.ts b/packages/web-server/src/web-images.ts index df93916ba..45aa3eb5d 100644 --- a/packages/web-server/src/web-images.ts +++ b/packages/web-server/src/web-images.ts @@ -106,6 +106,11 @@ export function inspectImage(bytes: Buffer, mediaType?: string): PreparedImage { /** Bounded, read-only local raster access for authenticated media or authorized Thread attachments. */ export function readImageFile(path: string): Buffer { + // Harness-written image links carry the POSIX-style drive prefix (`/D:/…`) + // the workspace file service canonicalizes; Windows resolution would + // otherwise read it as a `D:` directory under the current drive. A directory + // named `D:` is legal on POSIX, so the prefix is preserved there. + if (process.platform === "win32") path = path.replace(/^\/(?=[A-Za-z]:[/\\])/u, ""); let fd: number | undefined; try { if ( diff --git a/tests/dsh/workspace-files/changes.spec.ts b/tests/dsh/workspace-files/changes.spec.ts index 4c41ba263..d61071aa9 100644 --- a/tests/dsh/workspace-files/changes.spec.ts +++ b/tests/dsh/workspace-files/changes.spec.ts @@ -7,7 +7,7 @@ import type { Context } from "@deepseek-ai/cordis"; import type { FileSystem, FsObservation, FsTarget } from "@deepseek-ai/dsh-fs"; import { FsVersion } from "@deepseek-ai/dsh-fs"; import { WorkspaceFiles } from "@deepseek-ai/dsh-api-workspace-files"; -import { failureOf, openWorkspace, type Harness } from "./harness.ts"; +import { failureOf, openWorkspace, posixPrefixed, type Harness } from "./harness.ts"; let harness: Harness; let watch: MockInstance; @@ -135,6 +135,26 @@ describe("workspaceFiles.changes — target frames", () => { } }); + it.runIf(process.platform === "win32")( + "watches a target whose path carries the `/D:/`-style drive prefix harness links use", + async () => { + const path = join(harness.workspace, "prefixed.txt"); + await writeFile(path, "current contents"); + const service = harness.endpoint(); + const current = await service.stat(harness.scope, path, new AbortController().signal); + const stream = open(service, posixPrefixed(harness.workspace, "prefixed.txt")); + await ready(stream); + await observe(path, present("changed")); + await expect(stream.next()).resolves.toEqual({ + done: false, + value: { + kind: "change", + change: { absolutePath: current.absolutePath, version: current.version }, + }, + }); + }, + ); + it("waits for watcher readiness and retains invalidations delivered during initialization", async () => { const path = join(harness.workspace, "initializing.txt"); await writeFile(path, "ready contents"); diff --git a/tests/dsh/workspace-files/harness.ts b/tests/dsh/workspace-files/harness.ts index 323a2ef8f..af4b8f9ee 100644 --- a/tests/dsh/workspace-files/harness.ts +++ b/tests/dsh/workspace-files/harness.ts @@ -26,6 +26,15 @@ function fileScope(workspaceRoot: string): WorkspaceFileScope { return { sessionId: SessionId("s-test"), workspaceRoot }; } +/** + * A workspace path written the way harness links write Windows absolutes: a + * `/D:/`-style drive prefix over `/`-separated parts. Only meaningful on + * Windows, where the tests using it are gated. + */ +export function posixPrefixed(workspace: string, ...parts: string[]): string { + return `/${join(workspace, ...parts).replaceAll("\\", "/")}`; +} + export const signal = (): AbortSignal => new AbortController().signal; /** One temp workspace and the context serving it. */ diff --git a/tests/dsh/workspace-files/list.spec.ts b/tests/dsh/workspace-files/list.spec.ts index 0c97bbff4..a7e81a773 100644 --- a/tests/dsh/workspace-files/list.spec.ts +++ b/tests/dsh/workspace-files/list.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { mkdir, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { failureOf, openWorkspace, signal, type Harness } from "./harness.ts"; +import { failureOf, openWorkspace, posixPrefixed, signal, type Harness } from "./harness.ts"; let harness: Harness; let workspace: string; @@ -46,6 +46,16 @@ describe("workspaceFiles.list — the happy path", () => { expect(listing.entries).toEqual([]); }); + it.runIf(process.platform === "win32")( + "accepts the workspace root written with the `/D:/`-style prefix harness links carry", + async () => { + await writeFile(join(workspace, "notes.txt"), "hello", "utf8"); + const listing = await endpoint().list(harness.scope, posixPrefixed(workspace), signal()); + expect(listing.path).toBe(""); + expect(listing.entries).toEqual([{ name: "notes.txt", type: "file", size: 5 }]); + }, + ); + it("reports a nested directory as its `/`-joined path relative to the root, decoded", async () => { await mkdir(join(workspace, "src", "my dir", "子目录"), { recursive: true }); await writeFile(join(workspace, "src", "my dir", "子目录", "a.ts"), "", "utf8"); diff --git a/tests/dsh/workspace-files/read-all.spec.ts b/tests/dsh/workspace-files/read-all.spec.ts index f7a64faf5..d8ba34735 100644 --- a/tests/dsh/workspace-files/read-all.spec.ts +++ b/tests/dsh/workspace-files/read-all.spec.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mkdir, realpath, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { FsVersion } from "@deepseek-ai/dsh-fs"; -import { failureOf, openWorkspace, signal, type Harness } from "./harness.ts"; +import { failureOf, openWorkspace, posixPrefixed, signal, type Harness } from "./harness.ts"; let harness: Harness; @@ -109,6 +109,24 @@ describe("workspaceFiles.readBytes — complete files", () => { }); describe("workspaceFiles.readBytes — base file", () => { + it.runIf(process.platform === "win32")( + "resolves relative paths from a base file written with the `/D:/`-style prefix harness links carry", + async () => { + await mkdir(join(harness.workspace, "nested")); + await writeFile(join(harness.workspace, "nested/base.txt"), "base"); + await writeFile(join(harness.workspace, "nested/near.txt"), "near"); + const result = await harness + .endpoint() + .readBytes( + harness.scope, + "./near.txt", + { baseFile: posixPrefixed(harness.workspace, "nested", "base.txt") }, + signal(), + ); + expect(Buffer.from(result.data).toString()).toBe("near"); + }, + ); + it("resolves relative paths from the base file directory on the Host", async () => { await mkdir(join(harness.workspace, "nested")); await writeFile(join(harness.workspace, "nested/base.txt"), "base"); diff --git a/tests/dsh/workspace-files/read-bytes.spec.ts b/tests/dsh/workspace-files/read-bytes.spec.ts index 915f57aa7..651d9cebe 100644 --- a/tests/dsh/workspace-files/read-bytes.spec.ts +++ b/tests/dsh/workspace-files/read-bytes.spec.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { FsVersion } from "@deepseek-ai/dsh-fs"; -import { failureOf, openWorkspace, signal, type Harness } from "./harness.ts"; +import { failureOf, openWorkspace, posixPrefixed, signal, type Harness } from "./harness.ts"; let harness: Harness; let workspace: string; @@ -35,6 +35,21 @@ describe("workspaceFiles.readBytes — the window", () => { expect(result.version.length).toBeGreaterThan(0); }); + it.runIf(process.platform === "win32")( + "reads a Windows absolute path written with the `/D:/`-style prefix harness links carry", + async () => { + await writeFile(join(workspace, "ramp.bin"), RAMP); + const result = await endpoint().readBytes( + harness.scope, + posixPrefixed(workspace, "ramp.bin"), + { range: {} }, + signal(), + ); + expect(Buffer.from(result.data).equals(RAMP)).toBe(true); + expect(result.eof).toBe(true); + }, + ); + it("cuts the requested window and reports that more follows", async () => { await writeFile(join(workspace, "ramp.bin"), RAMP); const result = await endpoint().readBytes( diff --git a/tests/dsh/workspace-files/read.spec.ts b/tests/dsh/workspace-files/read.spec.ts index 36b06ed66..6dbdeae4a 100644 --- a/tests/dsh/workspace-files/read.spec.ts +++ b/tests/dsh/workspace-files/read.spec.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mkdir, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { FsError } from "@deepseek-ai/dsh-fs"; -import { failureOf, openWorkspace, signal, type Harness } from "./harness.ts"; +import { failureOf, openWorkspace, posixPrefixed, signal, type Harness } from "./harness.ts"; let harness: Harness; let workspace: string; @@ -337,3 +337,32 @@ describe("workspaceFiles.read — the file changing under its gate", () => { }); }); }); + +describe("workspaceFiles.read — POSIX-style drive prefixes", () => { + it.runIf(process.platform === "win32")( + "reads a Windows absolute path written with the `/D:/`-style prefix harness links carry", + async () => { + await writeFile(join(workspace, "notes.txt"), "hello\nworld\n", "utf8"); + const result = await endpoint().read( + harness.scope, + posixPrefixed(workspace, "notes.txt"), + {}, + signal(), + ); + expect(result.text).toBe("hello\nworld"); + expect(result.eof).toBe(true); + expect(result.bytes).toBe(12); + }, + ); + + it.runIf(process.platform !== "win32")( + "keeps the `/D:/` prefix on POSIX, where a `D:` directory is legal", + async () => { + const failure = await failureOf( + endpoint().read(harness.scope, "/D:/notes.txt", {}, signal()), + ); + expect(failure.code).toBe("workspace-file/not-found"); + expect(failure.details).toMatchObject({ path: "/D:/notes.txt" }); + }, + ); +}); diff --git a/tests/dsh/workspace-files/stat.spec.ts b/tests/dsh/workspace-files/stat.spec.ts index 5a3269af0..3c4793485 100644 --- a/tests/dsh/workspace-files/stat.spec.ts +++ b/tests/dsh/workspace-files/stat.spec.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { FsVersion } from "@deepseek-ai/dsh-fs"; -import { failureOf, openWorkspace, signal, type Harness } from "./harness.ts"; +import { failureOf, openWorkspace, posixPrefixed, signal, type Harness } from "./harness.ts"; let harness: Harness; @@ -28,6 +28,18 @@ describe("workspaceFiles.stat", () => { expect(result.bytes).toBe(6); }); + it.runIf(process.platform === "win32")( + "resolves a Windows absolute path written with the `/D:/`-style prefix harness links carry", + async () => { + await writeFile(join(harness.workspace, "notes.txt"), "hello\n", "utf8"); + const result = await harness + .endpoint() + .stat(harness.scope, posixPrefixed(harness.workspace, "notes.txt"), signal()); + expect(result.absolutePath.endsWith("notes.txt")).toBe(true); + expect(result.bytes).toBe(6); + }, + ); + it("answers with the version a read of the same file reports, and a new one after a write", async () => { const path = join(harness.workspace, "notes.txt"); await writeFile(path, "one\n", "utf8"); diff --git a/tests/integration/web-image-drive-prefix.test.ts b/tests/integration/web-image-drive-prefix.test.ts new file mode 100644 index 000000000..fc3cdc3f5 --- /dev/null +++ b/tests/integration/web-image-drive-prefix.test.ts @@ -0,0 +1,39 @@ +/** `/D:/`-style drive prefixes in image paths resolve like the plain Windows path. */ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, it } from "vitest"; +import { readImageFile } from "../../packages/web-server/src/web-images.ts"; + +/** A 1×1 PNG. */ +const PIXEL = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", +); + +let directory: string; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "web-image-drive-prefix-")); + await writeFile(join(directory, "pixel.png"), PIXEL); +}); + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }); +}); + +it("reads the image through the plain absolute path", () => { + expect(readImageFile(join(directory, "pixel.png"))).toEqual(PIXEL); +}); + +it.runIf(process.platform === "win32")( + "reads a Windows absolute path written with the `/D:/`-style prefix harness links carry", + () => { + const prefixed = `/${join(directory, "pixel.png").replaceAll("\\", "/")}`; + expect(readImageFile(prefixed)).toEqual(PIXEL); + }, +); + +it("still refuses a relative path", () => { + expect(() => readImageFile("pixel.png")).toThrow(); +});