diff --git a/packages/server/package.json b/packages/server/package.json index ee225cef86..fee28237ab 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts index 4bb4736133..2d3a58a3b7 100644 --- a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/filesystem.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(); diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index fdbfc697cf..5421450c24 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -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 { diff --git a/plugins/muse-provider/test/provider.test.ts b/plugins/muse-provider/test/provider.test.ts index 598579d04e..4868634d70 100644 --- a/plugins/muse-provider/test/provider.test.ts +++ b/plugins/muse-provider/test/provider.test.ts @@ -73,7 +73,8 @@ async function harness( predicate: (event: ProviderEvent) => boolean, from = 0, ): Promise { - 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();