Skip to content

Commit 8cb4c1d

Browse files
authored
Merge pull request #65 from Waishnav/codex/issue-58-agents-symlink
Resolve symlinked AGENTS context files
2 parents d031874 + 94afa09 commit 8cb4c1d

2 files changed

Lines changed: 81 additions & 14 deletions

File tree

src/workspaces.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,18 @@ import { WorkspaceRegistry } from "./workspaces.js";
1111

1212
const execFileAsync = promisify(execFile);
1313
const root = await mkdtemp(join(tmpdir(), "devspace-workspace-test-"));
14+
const outsideRoot = await mkdtemp(join(tmpdir(), "devspace-workspace-outside-test-"));
1415

1516
try {
1617
const agentDir = join(root, ".pi", "agent");
1718
await mkdir(agentDir, { recursive: true });
18-
await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n");
19+
await mkdir(join(agentDir, "skills"), { recursive: true });
20+
await writeFile(join(agentDir, "skills", "AGENTS.md"), "global instructions\n");
21+
if (platform() === "win32") {
22+
await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n");
23+
} else {
24+
await symlink("skills/AGENTS.md", join(agentDir, "AGENTS.md"));
25+
}
1926
await writeFile(join(root, "AGENTS.md"), "root instructions\n");
2027
await mkdir(join(root, ".devspace", "agents"), { recursive: true });
2128
await writeFile(
@@ -73,6 +80,26 @@ try {
7380
],
7481
);
7582

83+
if (platform() !== "win32") {
84+
const unsafeAgentDir = join(root, ".pi", "unsafe-agent");
85+
await mkdir(unsafeAgentDir, { recursive: true });
86+
await writeFile(join(outsideRoot, "secret.txt"), "outside secret\n");
87+
await symlink(join(outsideRoot, "secret.txt"), join(unsafeAgentDir, "AGENTS.md"));
88+
const unsafeConfig = loadConfig({
89+
DEVSPACE_CONFIG_DIR: join(root, ".devspace-unsafe-home"),
90+
DEVSPACE_ALLOWED_ROOTS: root,
91+
DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "unsafe-worktrees"),
92+
DEVSPACE_AGENT_DIR: unsafeAgentDir,
93+
DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough",
94+
PORT: "1",
95+
});
96+
const unsafeWorkspace = await new WorkspaceRegistry(unsafeConfig).openWorkspace(root);
97+
assert.deepEqual(
98+
unsafeWorkspace.agentsFiles.map((file) => file.content),
99+
["root instructions\n"],
100+
);
101+
}
102+
76103
const missingWorkspaceRoot = join(root, "missing", "workspace");
77104
const missingWorkspace = await registry.openWorkspace(missingWorkspaceRoot);
78105
assert.equal(missingWorkspace.workspace.root, missingWorkspaceRoot);
@@ -155,6 +182,7 @@ try {
155182
}
156183
} finally {
157184
await rm(root, { recursive: true, force: true });
185+
await rm(outsideRoot, { recursive: true, force: true });
158186
}
159187

160188
async function git(cwd: string, args: string[]): Promise<void> {

src/workspaces.ts

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { randomUUID } from "node:crypto";
22
import type { WorkspaceMode, WorkspaceStore } from "./workspace-store.js";
3-
import { mkdir, opendir, stat } from "node:fs/promises";
3+
import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises";
44
import { dirname, join, relative, resolve, sep } from "node:path";
55
import { loadProjectContextFiles } from "@earendil-works/pi-coding-agent";
66
import type { ServerConfig } from "./config.js";
@@ -220,7 +220,7 @@ export class WorkspaceRegistry {
220220
managed: workspace.worktree?.managed,
221221
});
222222
this.workspaces.set(workspace.id, workspace);
223-
const agentsFiles = this.loadInitialAgentsFiles(workspace.root);
223+
const agentsFiles = await this.loadInitialAgentsFiles(workspace.root);
224224
const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles);
225225

226226
return { workspace, agentsFiles, availableAgentsFiles };
@@ -246,32 +246,43 @@ export class WorkspaceRegistry {
246246
return assertAllowedPath(root, this.config.allowedRoots);
247247
}
248248

249-
private loadInitialAgentsFiles(root: string): LoadedAgentsFile[] {
249+
private async loadInitialAgentsFiles(root: string): Promise<LoadedAgentsFile[]> {
250250
const agentDir = resolve(this.config.agentDir);
251+
const loadedFiles: LoadedAgentsFile[] = [];
252+
253+
for (const file of loadProjectContextFiles({ cwd: root, agentDir })) {
254+
const path = resolve(file.path);
255+
if (!isInitialAgentsFilePath(path, root, agentDir)) continue;
256+
const content = await readResolvedContextFile(path, file.content, root, agentDir);
257+
if (content === undefined) continue;
258+
259+
loadedFiles.push({
260+
path,
261+
content,
262+
});
263+
}
251264

252-
return loadProjectContextFiles({ cwd: root, agentDir })
253-
.filter((file) => {
254-
const path = resolve(file.path);
255-
if (isPathInsideRoot(path, agentDir)) return true;
256-
return isPathInsideRoot(path, root) && dirname(path) === root;
257-
})
258-
.map((file) => ({
259-
path: resolve(file.path),
260-
content: file.content,
261-
}));
265+
return loadedFiles;
262266
}
263267

264268
private async findAvailableAgentsFiles(
265269
root: string,
266270
loadedFiles: LoadedAgentsFile[],
267271
): Promise<AvailableAgentsFile[]> {
268272
const loadedPaths = new Set(loadedFiles.map((file) => resolve(file.path)));
273+
const loadedRealPaths = new Set<string>();
274+
for (const file of loadedFiles) {
275+
const realPath = await tryRealpath(file.path);
276+
if (realPath) loadedRealPaths.add(realPath);
277+
}
269278
const discovered: AvailableAgentsFile[] = [];
270279

271280
await walkWorkspace(root, async (path, entry) => {
272281
if (!entry.isFile()) return;
273282
if (!CONTEXT_FILE_NAMES.has(entry.name)) return;
274283
if (loadedPaths.has(path)) return;
284+
const realPath = await tryRealpath(path);
285+
if (realPath && loadedRealPaths.has(realPath)) return;
275286

276287
discovered.push({ path });
277288
});
@@ -310,6 +321,34 @@ export function formatAgentsPath(path: string, workspaceRoot: string | undefined
310321
return relationship.split(sep).join("/");
311322
}
312323

324+
function isInitialAgentsFilePath(path: string, root: string, agentDir: string): boolean {
325+
if (isPathInsideRoot(path, agentDir)) return true;
326+
return isPathInsideRoot(path, root) && dirname(path) === root;
327+
}
328+
329+
async function readResolvedContextFile(
330+
path: string,
331+
fallbackContent: string,
332+
root: string,
333+
agentDir: string,
334+
): Promise<string | undefined> {
335+
try {
336+
const resolvedPath = await realpath(path);
337+
if (!isInitialAgentsFilePath(resolvedPath, root, agentDir)) return undefined;
338+
return await readFile(resolvedPath, "utf8");
339+
} catch {
340+
return fallbackContent;
341+
}
342+
}
343+
344+
async function tryRealpath(path: string): Promise<string | undefined> {
345+
try {
346+
return await realpath(path);
347+
} catch {
348+
return undefined;
349+
}
350+
}
351+
313352
async function walkWorkspace(
314353
directory: string,
315354
visit: (path: string, entry: { name: string; isFile(): boolean; isDirectory(): boolean }) => Promise<void> | void,

0 commit comments

Comments
 (0)