Skip to content

Commit 338c849

Browse files
committed
🤖 Fix symlink preservation when editing files in both runtimes
When editing a file through a symlink, both LocalRuntime and SSHRuntime were replacing the symlink with a regular file instead of writing through it to preserve the link. SSHRuntime now uses 'cat temp > target' instead of 'mv temp target' to write through symlinks. LocalRuntime resolves symlinks with fs.realpath() before writing to the actual target. Added integration test that verifies symlinks are preserved when editing files through them in both local and SSH runtimes.
1 parent ec66ae4 commit 338c849

File tree

3 files changed

+58
-4
lines changed

3 files changed

+58
-4
lines changed

src/runtime/LocalRuntime.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,15 +241,24 @@ export class LocalRuntime implements Runtime {
241241
writeFile(filePath: string): WritableStream<Uint8Array> {
242242
let tempPath: string;
243243
let writer: WritableStreamDefaultWriter<Uint8Array>;
244+
let resolvedPath: string;
244245

245246
return new WritableStream<Uint8Array>({
246247
async start() {
248+
// Resolve symlinks to write through them (preserves the symlink)
249+
try {
250+
resolvedPath = await fsPromises.realpath(filePath);
251+
} catch {
252+
// If file doesn't exist, use the original path
253+
resolvedPath = filePath;
254+
}
255+
247256
// Create parent directories if they don't exist
248-
const parentDir = path.dirname(filePath);
257+
const parentDir = path.dirname(resolvedPath);
249258
await fsPromises.mkdir(parentDir, { recursive: true });
250259

251260
// Create temp file for atomic write
252-
tempPath = `${filePath}.tmp.${Date.now()}`;
261+
tempPath = `${resolvedPath}.tmp.${Date.now()}`;
253262
const nodeStream = fs.createWriteStream(tempPath);
254263
const webStream = Writable.toWeb(nodeStream) as WritableStream<Uint8Array>;
255264
writer = webStream.getWriter();
@@ -261,7 +270,7 @@ export class LocalRuntime implements Runtime {
261270
// Close the writer and rename to final location
262271
await writer.close();
263272
try {
264-
await fsPromises.rename(tempPath, filePath);
273+
await fsPromises.rename(tempPath, resolvedPath);
265274
} catch (err) {
266275
throw new RuntimeErrorClass(
267276
`Failed to write file ${filePath}: ${err instanceof Error ? err.message : String(err)}`,

src/runtime/SSHRuntime.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,12 +263,14 @@ export class SSHRuntime implements Runtime {
263263

264264
/**
265265
* Write file contents over SSH atomically from a stream
266+
* Preserves symlinks by writing through them to the target file
266267
*/
267268
writeFile(path: string): WritableStream<Uint8Array> {
268269
const tempPath = `${path}.tmp.${Date.now()}`;
269270
// Create parent directory if needed, then write file atomically
271+
// Use cat to copy through symlinks (preserves them), then chmod and mv
270272
// Use shescape.quote for safe path escaping
271-
const writeCommand = `mkdir -p $(dirname ${shescape.quote(path)}) && cat > ${shescape.quote(tempPath)} && chmod 600 ${shescape.quote(tempPath)} && mv ${shescape.quote(tempPath)} ${shescape.quote(path)}`;
273+
const writeCommand = `mkdir -p $(dirname ${shescape.quote(path)}) && cat > ${shescape.quote(tempPath)} && chmod 600 ${shescape.quote(tempPath)} && cat ${shescape.quote(tempPath)} > ${shescape.quote(path)} && rm ${shescape.quote(tempPath)}`;
272274

273275
// Need to get the exec stream in async callbacks
274276
let execPromise: Promise<ExecStream> | null = null;

tests/runtime/runtime.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,49 @@ describeIntegration("Runtime integration tests", () => {
329329
const content = await readFileString(runtime, `${workspace.path}/special.txt`);
330330
expect(content).toBe(specialContent);
331331
});
332+
333+
test.concurrent("preserves symlinks when editing target file", async () => {
334+
const runtime = createRuntime();
335+
await using workspace = await TestWorkspace.create(runtime, type);
336+
337+
// Create a target file
338+
const targetPath = `${workspace.path}/target.txt`;
339+
await writeFileString(runtime, targetPath, "original content");
340+
341+
// Create a symlink to the target
342+
const linkPath = `${workspace.path}/link.txt`;
343+
const result = await execBuffered(runtime, `ln -s target.txt link.txt`, {
344+
cwd: workspace.path,
345+
timeout: 30,
346+
});
347+
expect(result.exitCode).toBe(0);
348+
349+
// Verify symlink was created
350+
const lsResult = await execBuffered(runtime, "ls -la link.txt", {
351+
cwd: workspace.path,
352+
timeout: 30,
353+
});
354+
expect(lsResult.stdout).toContain("->");
355+
expect(lsResult.stdout).toContain("target.txt");
356+
357+
// Edit the file via the symlink
358+
await writeFileString(runtime, linkPath, "new content");
359+
360+
// Verify the symlink is still a symlink (not replaced with a file)
361+
const lsAfter = await execBuffered(runtime, "ls -la link.txt", {
362+
cwd: workspace.path,
363+
timeout: 30,
364+
});
365+
expect(lsAfter.stdout).toContain("->");
366+
expect(lsAfter.stdout).toContain("target.txt");
367+
368+
// Verify both the symlink and target have the new content
369+
const linkContent = await readFileString(runtime, linkPath);
370+
expect(linkContent).toBe("new content");
371+
372+
const targetContent = await readFileString(runtime, targetPath);
373+
expect(targetContent).toBe("new content");
374+
});
332375
});
333376

334377
describe("stat() - File metadata", () => {

0 commit comments

Comments
 (0)