Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions sdk/typescript/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type FsErrorCode =
| 'ENOTEMPTY' // Directory not empty
| 'EPERM' // Operation not permitted
| 'EINVAL' // Invalid argument
| 'ELOOP' // Too many levels of symbolic links
| 'ENOSYS'; // Function not implemented (use for symlinks)

/**
Expand All @@ -18,6 +19,7 @@ export type FsErrorCode =
export type FsSyscall =
| 'open'
| 'stat'
| 'lstat'
| 'mkdir'
| 'rmdir'
| 'rm'
Expand Down
122 changes: 113 additions & 9 deletions sdk/typescript/src/filesystem/agentfs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,8 +318,32 @@ export class AgentFS implements FileSystem {
}

private normalizePath(path: string): string {
const normalized = path.replace(/\/+$/, '') || '/';
return normalized.startsWith('/') ? normalized : '/' + normalized;
// Ensure path starts with /
let normalized = path.startsWith('/') ? path : '/' + path;

// Remove trailing slashes (except for root)
normalized = normalized.replace(/\/+$/, '') || '/';

// Split into parts and resolve . and ..
const parts = normalized.split('/').filter(p => p);
const resolved: string[] = [];

for (const part of parts) {
if (part === '.') {
// Current directory - skip
continue;
} else if (part === '..') {
// Parent directory - pop if possible
if (resolved.length > 0) {
resolved.pop();
}
// If resolved is empty, we're at root, just ignore the ..
} else {
resolved.push(part);
}
}

return resolved.length === 0 ? '/' : '/' + resolved.join('/');
}

private splitPath(path: string): string[] {
Expand Down Expand Up @@ -620,7 +644,92 @@ export class AgentFS implements FileSystem {
}

async stat(path: string): Promise<Stats> {
const { normalizedPath, ino } = await this.resolvePathOrThrow(path, 'stat');
const MAX_SYMLINK_DEPTH = 40; // Standard POSIX limit for symlink following

let currentPath = this.normalizePath(path);

for (let depth = 0; depth < MAX_SYMLINK_DEPTH; depth++) {
const ino = await this.resolvePath(currentPath);
if (ino === null) {
throw createFsError({
code: 'ENOENT',
syscall: 'stat',
path: currentPath,
message: 'no such file or directory',
});
}

const stmt = this.db.prepare(`
SELECT ino, mode, nlink, uid, gid, size, atime, mtime, ctime
FROM fs_inode
WHERE ino = ?
`);
const row = await stmt.get(ino) as {
ino: number;
mode: number;
nlink: number;
uid: number;
gid: number;
size: number;
atime: number;
mtime: number;
ctime: number;
} | undefined;

if (!row) {
throw createFsError({
code: 'ENOENT',
syscall: 'stat',
path: currentPath,
message: 'no such file or directory',
});
}

// Check if this is a symlink
if ((row.mode & S_IFMT) === S_IFLNK) {
// Read the symlink target
const targetStmt = this.db.prepare('SELECT target FROM fs_symlink WHERE ino = ?');
const targetRow = await targetStmt.get(ino) as { target: string } | undefined;

if (!targetRow) {
throw createFsError({
code: 'ENOENT',
syscall: 'stat',
path: currentPath,
message: 'symlink has no target',
});
}

const target = targetRow.target;

// Resolve target path (handle both absolute and relative paths)
if (target.startsWith('/')) {
currentPath = this.normalizePath(target);
} else {
// Relative path - resolve relative to the symlink's directory
const parts = this.splitPath(currentPath);
parts.pop(); // Remove the symlink name
const parentPath = parts.length === 0 ? '/' : '/' + parts.join('/');
currentPath = this.normalizePath(parentPath + '/' + target);
}
continue; // Follow the symlink
}

// Not a symlink, return the stats
return createStats(row);
}

// Too many symlinks
throw createFsError({
code: 'ELOOP',
syscall: 'stat',
path: this.normalizePath(path),
message: 'too many levels of symbolic links',
});
}

async lstat(path: string): Promise<Stats> {
const { normalizedPath, ino } = await this.resolvePathOrThrow(path, 'lstat');

const stmt = this.db.prepare(`
SELECT ino, mode, nlink, uid, gid, size, atime, mtime, ctime
Expand All @@ -642,7 +751,7 @@ export class AgentFS implements FileSystem {
if (!row) {
throw createFsError({
code: 'ENOENT',
syscall: 'stat',
syscall: 'lstat',
path: normalizedPath,
message: 'no such file or directory',
});
Expand All @@ -651,11 +760,6 @@ export class AgentFS implements FileSystem {
return createStats(row);
}

async lstat(path: string): Promise<Stats> {
// For now, lstat is the same as stat since we don't follow symlinks in stat yet
return this.stat(path);
}

async mkdir(path: string): Promise<void> {
const normalizedPath = this.normalizePath(path);

Expand Down
111 changes: 111 additions & 0 deletions sdk/typescript/tests/filesystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1023,4 +1023,115 @@ describe("Filesystem Integration Tests", () => {
}
});
});

// ==================== Symlink Tests ====================

describe("Symlink Operations", () => {
it("should create and read a symlink", async () => {
await fs.writeFile("/target.txt", "target content");
await fs.symlink("/target.txt", "/link.txt");

const target = await fs.readlink("/link.txt");
expect(target).toBe("/target.txt");
});

it("should follow symlinks in stat()", async () => {
await fs.writeFile("/real-file.txt", "file content");
await fs.symlink("/real-file.txt", "/symlink-to-file.txt");

// stat() should follow the symlink and return the target file's stats
const stats = await fs.stat("/symlink-to-file.txt");
expect(stats.isFile()).toBe(true);
expect(stats.isSymbolicLink()).toBe(false);
expect(stats.size).toBe("file content".length);
});

it("should NOT follow symlinks in lstat()", async () => {
await fs.writeFile("/real-file2.txt", "file content");
await fs.symlink("/real-file2.txt", "/symlink-to-file2.txt");

// lstat() should return the symlink's own stats
const stats = await fs.lstat("/symlink-to-file2.txt");
expect(stats.isSymbolicLink()).toBe(true);
expect(stats.isFile()).toBe(false);
// Symlink size is the length of the target path
expect(stats.size).toBe("/real-file2.txt".length);
});

it("should follow symlinks to directories in stat()", async () => {
await fs.mkdir("/real-dir");
await fs.symlink("/real-dir", "/symlink-to-dir");

const stats = await fs.stat("/symlink-to-dir");
expect(stats.isDirectory()).toBe(true);
expect(stats.isSymbolicLink()).toBe(false);
});

it("should follow chain of symlinks in stat()", async () => {
await fs.writeFile("/final-target.txt", "final content");
await fs.symlink("/final-target.txt", "/link1.txt");
await fs.symlink("/link1.txt", "/link2.txt");
await fs.symlink("/link2.txt", "/link3.txt");

const stats = await fs.stat("/link3.txt");
expect(stats.isFile()).toBe(true);
expect(stats.size).toBe("final content".length);
});

it("should handle relative symlink targets", async () => {
await fs.writeFile("/dir/file.txt", "content in dir");
await fs.symlink("file.txt", "/dir/relative-link.txt");

const stats = await fs.stat("/dir/relative-link.txt");
expect(stats.isFile()).toBe(true);
expect(stats.size).toBe("content in dir".length);
});

it("should handle relative symlink targets with parent directory", async () => {
await fs.writeFile("/parent-file.txt", "parent content");
await fs.mkdir("/subdir");
await fs.symlink("../parent-file.txt", "/subdir/link-to-parent.txt");

const stats = await fs.stat("/subdir/link-to-parent.txt");
expect(stats.isFile()).toBe(true);
expect(stats.size).toBe("parent content".length);
});

it("should throw ELOOP for symlink loops", async () => {
await fs.symlink("/loop-b", "/loop-a");
await fs.symlink("/loop-a", "/loop-b");

await expect(fs.stat("/loop-a")).rejects.toMatchObject({ code: "ELOOP" });
});

it("should throw ELOOP for self-referencing symlink", async () => {
await fs.symlink("/self-ref", "/self-ref");

await expect(fs.stat("/self-ref")).rejects.toMatchObject({ code: "ELOOP" });
});

it("should throw ENOENT when symlink target does not exist", async () => {
await fs.symlink("/nonexistent-target.txt", "/dangling-link.txt");

await expect(fs.stat("/dangling-link.txt")).rejects.toMatchObject({ code: "ENOENT" });
});

it("should return symlink stats with lstat() for dangling symlink", async () => {
await fs.symlink("/nonexistent.txt", "/dangling.txt");

// lstat should work even if target doesn't exist
const stats = await fs.lstat("/dangling.txt");
expect(stats.isSymbolicLink()).toBe(true);
});

it("should throw EEXIST when creating symlink at existing path", async () => {
await fs.writeFile("/exists.txt", "content");
await expect(fs.symlink("/target", "/exists.txt")).rejects.toMatchObject({ code: "EEXIST" });
});

it("should throw EINVAL when readlink is called on a non-symlink", async () => {
await fs.writeFile("/regular-file.txt", "content");
await expect(fs.readlink("/regular-file.txt")).rejects.toMatchObject({ code: "EINVAL" });
});
});
});