diff --git a/src/runtime/LocalRuntime.ts b/src/runtime/LocalRuntime.ts index ccbc057fd..e1ecf7bb3 100644 --- a/src/runtime/LocalRuntime.ts +++ b/src/runtime/LocalRuntime.ts @@ -241,15 +241,29 @@ export class LocalRuntime implements Runtime { writeFile(filePath: string): WritableStream { let tempPath: string; let writer: WritableStreamDefaultWriter; + let resolvedPath: string; + let originalMode: number | undefined; return new WritableStream({ async start() { + // Resolve symlinks to write through them (preserves the symlink) + try { + resolvedPath = await fsPromises.realpath(filePath); + // Save original permissions to restore after write + const stat = await fsPromises.stat(resolvedPath); + originalMode = stat.mode; + } catch { + // If file doesn't exist, use the original path and default permissions + resolvedPath = filePath; + originalMode = undefined; + } + // Create parent directories if they don't exist - const parentDir = path.dirname(filePath); + const parentDir = path.dirname(resolvedPath); await fsPromises.mkdir(parentDir, { recursive: true }); // Create temp file for atomic write - tempPath = `${filePath}.tmp.${Date.now()}`; + tempPath = `${resolvedPath}.tmp.${Date.now()}`; const nodeStream = fs.createWriteStream(tempPath); const webStream = Writable.toWeb(nodeStream) as WritableStream; writer = webStream.getWriter(); @@ -261,7 +275,11 @@ export class LocalRuntime implements Runtime { // Close the writer and rename to final location await writer.close(); try { - await fsPromises.rename(tempPath, filePath); + // If we have original permissions, apply them to temp file before rename + if (originalMode !== undefined) { + await fsPromises.chmod(tempPath, originalMode); + } + await fsPromises.rename(tempPath, resolvedPath); } catch (err) { throw new RuntimeErrorClass( `Failed to write file ${filePath}: ${err instanceof Error ? err.message : String(err)}`, diff --git a/src/runtime/SSHRuntime.ts b/src/runtime/SSHRuntime.ts index ba10128f5..16181026e 100644 --- a/src/runtime/SSHRuntime.ts +++ b/src/runtime/SSHRuntime.ts @@ -263,12 +263,16 @@ export class SSHRuntime implements Runtime { /** * Write file contents over SSH atomically from a stream + * Preserves symlinks and file permissions by resolving and copying metadata */ writeFile(path: string): WritableStream { const tempPath = `${path}.tmp.${Date.now()}`; - // Create parent directory if needed, then write file atomically + // Resolve symlinks to get the actual target path, preserving the symlink itself + // If target exists, save its permissions to restore after write + // If path doesn't exist, use 600 as default + // Then write atomically using mv (all-or-nothing for readers) // Use shescape.quote for safe path escaping - const writeCommand = `mkdir -p $(dirname ${shescape.quote(path)}) && cat > ${shescape.quote(tempPath)} && chmod 600 ${shescape.quote(tempPath)} && mv ${shescape.quote(tempPath)} ${shescape.quote(path)}`; + const writeCommand = `RESOLVED=$(readlink -f ${shescape.quote(path)} 2>/dev/null || echo ${shescape.quote(path)}) && PERMS=$(stat -c '%a' "$RESOLVED" 2>/dev/null || echo 600) && mkdir -p $(dirname "$RESOLVED") && cat > ${shescape.quote(tempPath)} && chmod "$PERMS" ${shescape.quote(tempPath)} && mv ${shescape.quote(tempPath)} "$RESOLVED"`; // Need to get the exec stream in async callbacks let execPromise: Promise | null = null; diff --git a/tests/runtime/runtime.test.ts b/tests/runtime/runtime.test.ts index 86a6463ee..27e4ce020 100644 --- a/tests/runtime/runtime.test.ts +++ b/tests/runtime/runtime.test.ts @@ -329,6 +329,94 @@ describeIntegration("Runtime integration tests", () => { const content = await readFileString(runtime, `${workspace.path}/special.txt`); expect(content).toBe(specialContent); }); + + test.concurrent("preserves symlinks when editing target file", async () => { + const runtime = createRuntime(); + await using workspace = await TestWorkspace.create(runtime, type); + + // Create a target file + const targetPath = `${workspace.path}/target.txt`; + await writeFileString(runtime, targetPath, "original content"); + + // Create a symlink to the target + const linkPath = `${workspace.path}/link.txt`; + const result = await execBuffered(runtime, `ln -s target.txt link.txt`, { + cwd: workspace.path, + timeout: 30, + }); + expect(result.exitCode).toBe(0); + + // Verify symlink was created + const lsResult = await execBuffered(runtime, "ls -la link.txt", { + cwd: workspace.path, + timeout: 30, + }); + expect(lsResult.stdout).toContain("->"); + expect(lsResult.stdout).toContain("target.txt"); + + // Edit the file via the symlink + await writeFileString(runtime, linkPath, "new content"); + + // Verify the symlink is still a symlink (not replaced with a file) + const lsAfter = await execBuffered(runtime, "ls -la link.txt", { + cwd: workspace.path, + timeout: 30, + }); + expect(lsAfter.stdout).toContain("->"); + expect(lsAfter.stdout).toContain("target.txt"); + + // Verify both the symlink and target have the new content + const linkContent = await readFileString(runtime, linkPath); + expect(linkContent).toBe("new content"); + + const targetContent = await readFileString(runtime, targetPath); + expect(targetContent).toBe("new content"); + }); + + test.concurrent("preserves file permissions when editing through symlink", async () => { + const runtime = createRuntime(); + await using workspace = await TestWorkspace.create(runtime, type); + + // Create a target file with specific permissions (755) + const targetPath = `${workspace.path}/target.txt`; + await writeFileString(runtime, targetPath, "original content"); + + // Set permissions to 755 + const chmodResult = await execBuffered(runtime, "chmod 755 target.txt", { + cwd: workspace.path, + timeout: 30, + }); + expect(chmodResult.exitCode).toBe(0); + + // Verify initial permissions + const statBefore = await execBuffered(runtime, "stat -c '%a' target.txt", { + cwd: workspace.path, + timeout: 30, + }); + expect(statBefore.stdout.trim()).toBe("755"); + + // Create a symlink to the target + const linkPath = `${workspace.path}/link.txt`; + const lnResult = await execBuffered(runtime, "ln -s target.txt link.txt", { + cwd: workspace.path, + timeout: 30, + }); + expect(lnResult.exitCode).toBe(0); + + // Edit the file via the symlink + await writeFileString(runtime, linkPath, "new content"); + + // Verify permissions are preserved + const statAfter = await execBuffered(runtime, "stat -c '%a' target.txt", { + cwd: workspace.path, + timeout: 30, + }); + expect(statAfter.stdout.trim()).toBe("755"); + + // Verify content was updated + const content = await readFileString(runtime, targetPath); + expect(content).toBe("new content"); + }); }); describe("stat() - File metadata", () => {