Keep files starting with two dots usable (#6604)

* fix(file-explorer): distinguish parent traversal from dot-prefixed names

* test(file-explorer): cover dot-prefixed paths through daemon RPC

* test(plugins): allow slower session readiness in provider fixtures

The event helper exhausted 200 iterations before a valid delayed session
became ready. Use an elapsed-time deadline while preserving real timers
for watchdog tests, and retain a delayed-start regression with launch
flag assertions.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-11 16:51:00 +02:00
committed by GitHub
co-authored by 见安
parent 804335bc00
commit 114389383d
4 changed files with 192 additions and 5 deletions
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -1,5 +1,5 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "fs";
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, symlinkSync, readdirSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
@@ -23,6 +23,179 @@ afterEach(async () => {
}, 60000);
describe("file explorer", () => {
test.each(["..", "..."])("supports file operations on names starting with %s", async (prefix) => {
const cwd = tmpCwd();
const fileName = `${prefix}notes.txt`;
const directoryName = `${prefix}drafts`;
try {
const createdFile = await ctx.client.createFileEntry({
cwd,
parentPath: ".",
name: fileName,
kind: "file",
});
expect(createdFile).toMatchObject({ path: fileName, success: true, error: null });
writeFileSync(path.join(cwd, fileName), "notes payload\n");
writeFileSync(path.join(cwd, ".notes.txt"), "dotfile control\n");
writeFileSync(path.join(cwd, "notes.txt"), "ordinary control\n");
const createdDirectory = await ctx.client.createFileEntry({
cwd,
parentPath: ".",
name: directoryName,
kind: "directory",
});
expect(createdDirectory).toMatchObject({ path: directoryName, success: true, error: null });
const createdChild = await ctx.client.createFileEntry({
cwd,
parentPath: directoryName,
name: "inside.txt",
kind: "file",
});
expect(createdChild).toMatchObject({
path: `${directoryName}/inside.txt`,
success: true,
error: null,
});
writeFileSync(path.join(cwd, directoryName, "inside.txt"), "nested payload\n");
const directory = await ctx.client.listDirectory(cwd, ".");
expect(
Object.fromEntries(directory.entries.map((entry) => [entry.path, entry.kind])),
).toEqual({
[directoryName]: "directory",
[fileName]: "file",
".notes.txt": "file",
"notes.txt": "file",
});
const children = await ctx.client.listDirectory(cwd, directoryName);
expect(children.entries.map((entry) => entry.path)).toEqual([`${directoryName}/inside.txt`]);
const file = await ctx.client.readFile(cwd, fileName);
expect(file.path).toBe(fileName);
expect(new TextDecoder().decode(file.bytes)).toBe("notes payload\n");
const nestedFile = await ctx.client.readFile(cwd, `${directoryName}/inside.txt`);
expect(nestedFile.path).toBe(`${directoryName}/inside.txt`);
expect(new TextDecoder().decode(nestedFile.bytes)).toBe("nested payload\n");
const dotfile = await ctx.client.readFile(cwd, ".notes.txt");
expect(new TextDecoder().decode(dotfile.bytes)).toBe("dotfile control\n");
const ordinaryFile = await ctx.client.readFile(cwd, "notes.txt");
expect(new TextDecoder().decode(ordinaryFile.bytes)).toBe("ordinary control\n");
const download = await ctx.client.requestDownloadToken(cwd, fileName);
expect(download.error).toBeNull();
expect(download.fileName).toBe(fileName);
const response = await fetch(
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${download.token}`,
);
expect(response.status).toBe(200);
expect(await response.text()).toBe("notes payload\n");
const renamedFileName = `${prefix}renamed.txt`;
const renamed = await ctx.client.renameFileEntry({
cwd,
path: fileName,
name: renamedFileName,
});
expect(renamed).toMatchObject({ renamedPath: renamedFileName, success: true, error: null });
const renamedFile = await ctx.client.readFile(cwd, renamedFileName);
expect(new TextDecoder().decode(renamedFile.bytes)).toBe("notes payload\n");
const copiedDirectoryName = `${directoryName} copy`;
const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: directoryName });
expect(duplicated).toMatchObject({
duplicatedPath: copiedDirectoryName,
success: true,
error: null,
});
const copiedFile = await ctx.client.readFile(cwd, `${copiedDirectoryName}/inside.txt`);
expect(new TextDecoder().decode(copiedFile.bytes)).toBe("nested payload\n");
const deletedDirectory = await ctx.client.deleteFileEntry({ cwd, path: directoryName });
expect(deletedDirectory).toMatchObject({ success: true, error: null });
const deletedFile = await ctx.client.deleteFileEntry({ cwd, path: renamedFileName });
expect(deletedFile).toMatchObject({ success: true, error: null });
const remaining = await ctx.client.listDirectory(cwd, ".");
expect(remaining.entries.map((entry) => entry.path).sort()).toEqual(
[".notes.txt", "notes.txt", copiedDirectoryName].sort(),
);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("rejects parent traversal and absolute paths to a sibling workspace", async () => {
const directory = tmpCwd();
const cwd = path.join(directory, "workspace");
const outside = path.join(directory, "workspace-sibling");
try {
mkdirSync(cwd);
mkdirSync(outside);
writeFileSync(path.join(outside, "secret.txt"), "outside payload\n");
for (const requestedPath of ["..", "../workspace-sibling", outside]) {
await expect(ctx.client.listDirectory(cwd, requestedPath)).rejects.toThrow(
"Access outside of workspace is not allowed",
);
}
for (const requestedPath of [
"../workspace-sibling/secret.txt",
path.join(outside, "secret.txt"),
]) {
await expect(ctx.client.readFile(cwd, requestedPath)).rejects.toThrow(
"Access outside of workspace is not allowed",
);
const download = await ctx.client.requestDownloadToken(cwd, requestedPath);
expect(download).toMatchObject({
token: null,
error: "Access outside of workspace is not allowed",
});
const deleted = await ctx.client.deleteFileEntry({ cwd, path: requestedPath });
expect(deleted).toMatchObject({
success: false,
error: "Access outside of workspace is not allowed",
});
}
expect(readdirSync(outside)).toEqual(["secret.txt"]);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("accepts symlinks to double-dot names inside the workspace and rejects outside targets", async () => {
const directory = tmpCwd();
const cwd = path.join(directory, "workspace");
const outside = path.join(directory, "workspace-sibling");
try {
mkdirSync(path.join(cwd, "..drafts"), { recursive: true });
mkdirSync(outside);
writeFileSync(path.join(cwd, "..drafts", "notes.txt"), "inside payload\n");
writeFileSync(path.join(outside, "secret.txt"), "outside payload\n");
// Junctions also work on Windows without requiring symlink privileges.
symlinkSync(path.join(cwd, "..drafts"), path.join(cwd, "inside-link"), "junction");
symlinkSync(outside, path.join(cwd, "outside-link"), "junction");
const listing = await ctx.client.listDirectory(cwd, ".");
expect(listing.entries.map((entry) => entry.path).sort()).toEqual([
"..drafts",
"inside-link",
]);
const file = await ctx.client.readFile(cwd, "inside-link/notes.txt");
expect(file.path).toBe("inside-link/notes.txt");
expect(new TextDecoder().decode(file.bytes)).toBe("inside payload\n");
await expect(ctx.client.readFile(cwd, "outside-link/secret.txt")).rejects.toThrow(
"Access outside of workspace is not allowed",
);
const download = await ctx.client.requestDownloadToken(cwd, "outside-link/secret.txt");
expect(download).toMatchObject({
token: null,
error: "Access outside of workspace is not allowed",
});
expect(readdirSync(outside)).toEqual(["secret.txt"]);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("lists directory contents", async () => {
const cwd = tmpCwd();
@@ -824,8 +824,9 @@ async function resolveScopedPath({
function assertWithinWorkspace(root: string, candidate: string): void {
const relative = path.relative(root, candidate);
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return;
throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE);
const isOutsideWorkspace =
relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
if (isOutsideWorkspace) throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE);
}
async function openFileForRead(filePath: string): Promise<FileHandle> {
+14 -1
View File
@@ -73,7 +73,8 @@ async function harness(
predicate: (event: ProviderEvent) => boolean,
from = 0,
): Promise<ProviderEvent> {
for (let count = 0; count < 200; count++) {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
const event = events.slice(from).find(predicate);
if (event) return event;
await delay(10);
@@ -1219,6 +1220,18 @@ test("silent active turns page after two minutes and stop polling on recovered t
await vi.advanceTimersByTimeAsync(240000);
expect((await h.recorded()).filter((f) => f.method === "view/page")).toHaveLength(1);
});
test("session readiness accepts initialization beyond the old event polling window", async () => {
const h = await harness("text-reasoning", { MUSE_TEST_INITIALIZE_DELAY_MS: "3500" });
expect(await h.open()).toMatchObject({ type: "session.ready" });
expect((await h.recorded()).find((frame) => frame.event === "hostStarted").args).toEqual([
"serve",
"--sandbox-network",
"proxy-only",
"--disable-sandbox",
"--trust-workspace",
]);
}, 10000);
test("default launch disables sandbox and trusts workspace", async () => {
const h = await harness();
await h.open();