From 4e10331b17585c34e899cfc870f1d10cbfbee668 Mon Sep 17 00:00:00 2001 From: hhucz <1263616357@qq.com> Date: Tue, 10 Nov 2026 01:55:19 +0800 Subject: [PATCH 1/3] fix(web): resolve POSIX-style drive paths in workspace file and image links Harness-written Markdown links carry Windows absolute paths in a POSIX-style drive form (/D:/...). The Web workspace file service resolved that form against the workspace root, where win32 resolution reads /D: as a directory named "D:" under the current drive - an existing file then failed with workspace-file/not-found and the document preview reported it missing. readImageFile, behind GET /api/file and attachment previews, passed its isAbsolute gate on the prefixed form but missed the file the same way. Canonicalize the prefix away at the five public WorkspaceFiles endpoints (read, readBytes, stat, list, changes) and in readImageFile, matching the Desktop renderer's own handling of such links. Plain workspace-absolute, drive-letter, device (\?\C:\) and UNC paths are unchanged. Co-Authored-By: Claude Code --- .../packages/api/workspace-files/src/index.ts | 19 ++++++++- packages/web-server/src/web-images.ts | 4 ++ tests/dsh/workspace-files/changes.spec.ts | 22 ++++++++++- tests/dsh/workspace-files/harness.ts | 9 +++++ tests/dsh/workspace-files/list.spec.ts | 12 +++++- tests/dsh/workspace-files/read-bytes.spec.ts | 17 +++++++- tests/dsh/workspace-files/read.spec.ts | 20 +++++++++- tests/dsh/workspace-files/stat.spec.ts | 14 ++++++- .../web-image-drive-prefix.test.ts | 39 +++++++++++++++++++ 9 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 tests/integration/web-image-drive-prefix.test.ts 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..0d78c5ec8 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,16 @@ 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. + */ +function stripPosixDrivePrefix(path: string): string { + return path.replace(/^\/(?=[A-Za-z]:[/\\])/u, ""); +} + /** * 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 +272,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,6 +303,7 @@ 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 resolved = @@ -337,6 +351,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 +369,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 +414,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..51d3f5ef7 100644 --- a/packages/web-server/src/web-images.ts +++ b/packages/web-server/src/web-images.ts @@ -106,6 +106,10 @@ 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. + 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-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..4d3f91f5f 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,21 @@ 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); + }, + ); +}); 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(); +}); From fc9be5885ee7a097376e9cda8bed2d98f7a9691c Mon Sep 17 00:00:00 2001 From: hhucz <1263616357@qq.com> Date: Tue, 10 Nov 2026 02:16:00 +0800 Subject: [PATCH 2/3] fix(web): gate drive-prefix canonicalization by platform and cover baseFile CodeRabbit review follow-up: a directory named D: is legal on POSIX, so the `/D:/` prefix is now stripped only on Windows, in both the WorkspaceFiles service and readImageFile; POSIX keeps the path verbatim. Also normalize `options.baseFile` in readBytes, so a relative path against a prefixed base file resolves instead of failing with workspace-file/not-found. Adds a POSIX-preservation case (skipped on Windows) and a prefixed-baseFile case, with a red-state run confirming both fail without their fix. Co-Authored-By: Claude Code --- .../packages/api/workspace-files/src/index.ts | 11 ++++++---- packages/web-server/src/web-images.ts | 5 +++-- tests/dsh/workspace-files/read-all.spec.ts | 20 ++++++++++++++++++- tests/dsh/workspace-files/read.spec.ts | 11 ++++++++++ 4 files changed, 40 insertions(+), 7 deletions(-) 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 0d78c5ec8..c59e525a0 100644 --- a/apps/web-ui/packages/api/workspace-files/src/index.ts +++ b/apps/web-ui/packages/api/workspace-files/src/index.ts @@ -118,10 +118,11 @@ function integerAtLeast(value: number, min: number, name: string): number { * 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. + * 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 path.replace(/^\/(?=[A-Za-z]:[/\\])/u, ""); + return process.platform === "win32" ? path.replace(/^\/(?=[A-Za-z]:[/\\])/u, "") : path; } /** @@ -306,10 +307,12 @@ export class WorkspaceFiles extends TypertRemoteService { 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; diff --git a/packages/web-server/src/web-images.ts b/packages/web-server/src/web-images.ts index 51d3f5ef7..45aa3eb5d 100644 --- a/packages/web-server/src/web-images.ts +++ b/packages/web-server/src/web-images.ts @@ -108,8 +108,9 @@ export function inspectImage(bytes: Buffer, mediaType?: string): PreparedImage { 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. - path = path.replace(/^\/(?=[A-Za-z]:[/\\])/u, ""); + // 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/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.spec.ts b/tests/dsh/workspace-files/read.spec.ts index 4d3f91f5f..6dbdeae4a 100644 --- a/tests/dsh/workspace-files/read.spec.ts +++ b/tests/dsh/workspace-files/read.spec.ts @@ -354,4 +354,15 @@ describe("workspaceFiles.read — POSIX-style drive prefixes", () => { 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" }); + }, + ); }); From 346bc723dde9c664929dfc76a53114675c4a4341 Mon Sep 17 00:00:00 2001 From: hhucz <1263616357@qq.com> Date: Tue, 10 Nov 2026 02:24:40 +0800 Subject: [PATCH 3/3] ci: retrigger - matrix legs failed to start in run 42672553044 The pull_request run for fc9be58 concluded failure with only the ARM64 leg created; ubuntu/macos/windows jobs never materialized, and rerun needs upstream admin rights. Upstream main is unchanged (85a72c1), and the previous run for 4e10331b expanded all four legs and passed, so this looks like a GitHub-side runner provisioning hiccup rather than a configuration problem. Co-Authored-By: Claude Code