mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user