Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions apps/web-ui/packages/api/workspace-files/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -260,6 +273,7 @@ export class WorkspaceFiles extends TypertRemoteService {
range: WorkspaceFileRange,
signal: AbortSignal,
): Promise<WorkspaceFileText> {
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);
Expand Down Expand Up @@ -290,12 +304,15 @@ export class WorkspaceFiles extends TypertRemoteService {
options: WorkspaceByteReadOptions,
signal: AbortSignal,
): Promise<WorkspaceFileBytes> {
path = stripPosixDrivePrefix(path);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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;
Expand Down Expand Up @@ -337,6 +354,7 @@ export class WorkspaceFiles extends TypertRemoteService {
path: string,
signal: AbortSignal,
): Promise<WorkspaceFileStat> {
path = stripPosixDrivePrefix(path);
const { target, info } = await this.locateFile(workspaceFileScope, path, signal);
return this.statOf(target, info);
}
Expand All @@ -354,6 +372,7 @@ export class WorkspaceFiles extends TypertRemoteService {
path: string,
signal: AbortSignal,
): Promise<WorkspaceDirectoryListing> {
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
Expand Down Expand Up @@ -398,6 +417,7 @@ export class WorkspaceFiles extends TypertRemoteService {
path: string,
signal: AbortSignal,
): AsyncIterable<WorkspaceFileWatchFrame> {
path = stripPosixDrivePrefix(path);
return this.feed.follow(workspaceFileScope.workspaceRoot, path, signal);
}

Expand Down
5 changes: 5 additions & 0 deletions packages/web-server/src/web-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
22 changes: 21 additions & 1 deletion tests/dsh/workspace-files/changes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileSystem["watch"]>;
Expand Down Expand Up @@ -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");
Expand Down
9 changes: 9 additions & 0 deletions tests/dsh/workspace-files/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
12 changes: 11 additions & 1 deletion tests/dsh/workspace-files/list.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
20 changes: 19 additions & 1 deletion tests/dsh/workspace-files/read-all.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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");
Expand Down
17 changes: 16 additions & 1 deletion tests/dsh/workspace-files/read-bytes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
31 changes: 30 additions & 1 deletion tests/dsh/workspace-files/read.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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" });
},
);
});
14 changes: 13 additions & 1 deletion tests/dsh/workspace-files/stat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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");
Expand Down
39 changes: 39 additions & 0 deletions tests/integration/web-image-drive-prefix.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Loading