diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 87f6b35a..7b8136fd 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -11,11 +11,18 @@ import { WorkspaceRegistry } from "./workspaces.js"; const execFileAsync = promisify(execFile); const root = await mkdtemp(join(tmpdir(), "devspace-workspace-test-")); +const outsideRoot = await mkdtemp(join(tmpdir(), "devspace-workspace-outside-test-")); try { const agentDir = join(root, ".pi", "agent"); await mkdir(agentDir, { recursive: true }); - await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n"); + await mkdir(join(agentDir, "skills"), { recursive: true }); + await writeFile(join(agentDir, "skills", "AGENTS.md"), "global instructions\n"); + if (platform() === "win32") { + await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n"); + } else { + await symlink("skills/AGENTS.md", join(agentDir, "AGENTS.md")); + } await writeFile(join(root, "AGENTS.md"), "root instructions\n"); await mkdir(join(root, ".devspace", "agents"), { recursive: true }); await writeFile( @@ -73,6 +80,26 @@ try { ], ); + if (platform() !== "win32") { + const unsafeAgentDir = join(root, ".pi", "unsafe-agent"); + await mkdir(unsafeAgentDir, { recursive: true }); + await writeFile(join(outsideRoot, "secret.txt"), "outside secret\n"); + await symlink(join(outsideRoot, "secret.txt"), join(unsafeAgentDir, "AGENTS.md")); + const unsafeConfig = loadConfig({ + DEVSPACE_CONFIG_DIR: join(root, ".devspace-unsafe-home"), + DEVSPACE_ALLOWED_ROOTS: root, + DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "unsafe-worktrees"), + DEVSPACE_AGENT_DIR: unsafeAgentDir, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + const unsafeWorkspace = await new WorkspaceRegistry(unsafeConfig).openWorkspace(root); + assert.deepEqual( + unsafeWorkspace.agentsFiles.map((file) => file.content), + ["root instructions\n"], + ); + } + const missingWorkspaceRoot = join(root, "missing", "workspace"); const missingWorkspace = await registry.openWorkspace(missingWorkspaceRoot); assert.equal(missingWorkspace.workspace.root, missingWorkspaceRoot); @@ -155,6 +182,7 @@ try { } } finally { await rm(root, { recursive: true, force: true }); + await rm(outsideRoot, { recursive: true, force: true }); } async function git(cwd: string, args: string[]): Promise { diff --git a/src/workspaces.ts b/src/workspaces.ts index 673d0823..466b1b40 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import type { WorkspaceMode, WorkspaceStore } from "./workspace-store.js"; -import { mkdir, opendir, stat } from "node:fs/promises"; +import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises"; import { dirname, join, relative, resolve, sep } from "node:path"; import { loadProjectContextFiles } from "@earendil-works/pi-coding-agent"; import type { ServerConfig } from "./config.js"; @@ -220,7 +220,7 @@ export class WorkspaceRegistry { managed: workspace.worktree?.managed, }); this.workspaces.set(workspace.id, workspace); - const agentsFiles = this.loadInitialAgentsFiles(workspace.root); + const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); return { workspace, agentsFiles, availableAgentsFiles }; @@ -246,19 +246,23 @@ export class WorkspaceRegistry { return assertAllowedPath(root, this.config.allowedRoots); } - private loadInitialAgentsFiles(root: string): LoadedAgentsFile[] { + private async loadInitialAgentsFiles(root: string): Promise { const agentDir = resolve(this.config.agentDir); + const loadedFiles: LoadedAgentsFile[] = []; + + for (const file of loadProjectContextFiles({ cwd: root, agentDir })) { + const path = resolve(file.path); + if (!isInitialAgentsFilePath(path, root, agentDir)) continue; + const content = await readResolvedContextFile(path, file.content, root, agentDir); + if (content === undefined) continue; + + loadedFiles.push({ + path, + content, + }); + } - return loadProjectContextFiles({ cwd: root, agentDir }) - .filter((file) => { - const path = resolve(file.path); - if (isPathInsideRoot(path, agentDir)) return true; - return isPathInsideRoot(path, root) && dirname(path) === root; - }) - .map((file) => ({ - path: resolve(file.path), - content: file.content, - })); + return loadedFiles; } private async findAvailableAgentsFiles( @@ -266,12 +270,19 @@ export class WorkspaceRegistry { loadedFiles: LoadedAgentsFile[], ): Promise { const loadedPaths = new Set(loadedFiles.map((file) => resolve(file.path))); + const loadedRealPaths = new Set(); + for (const file of loadedFiles) { + const realPath = await tryRealpath(file.path); + if (realPath) loadedRealPaths.add(realPath); + } const discovered: AvailableAgentsFile[] = []; await walkWorkspace(root, async (path, entry) => { if (!entry.isFile()) return; if (!CONTEXT_FILE_NAMES.has(entry.name)) return; if (loadedPaths.has(path)) return; + const realPath = await tryRealpath(path); + if (realPath && loadedRealPaths.has(realPath)) return; discovered.push({ path }); }); @@ -310,6 +321,34 @@ export function formatAgentsPath(path: string, workspaceRoot: string | undefined return relationship.split(sep).join("/"); } +function isInitialAgentsFilePath(path: string, root: string, agentDir: string): boolean { + if (isPathInsideRoot(path, agentDir)) return true; + return isPathInsideRoot(path, root) && dirname(path) === root; +} + +async function readResolvedContextFile( + path: string, + fallbackContent: string, + root: string, + agentDir: string, +): Promise { + try { + const resolvedPath = await realpath(path); + if (!isInitialAgentsFilePath(resolvedPath, root, agentDir)) return undefined; + return await readFile(resolvedPath, "utf8"); + } catch { + return fallbackContent; + } +} + +async function tryRealpath(path: string): Promise { + try { + return await realpath(path); + } catch { + return undefined; + } +} + async function walkWorkspace( directory: string, visit: (path: string, entry: { name: string; isFile(): boolean; isDirectory(): boolean }) => Promise | void,