Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 12 additions & 3 deletions src/runtime/LocalRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,15 +241,24 @@ export class LocalRuntime implements Runtime {
writeFile(filePath: string): WritableStream<Uint8Array> {
let tempPath: string;
let writer: WritableStreamDefaultWriter<Uint8Array>;
let resolvedPath: string;

return new WritableStream<Uint8Array>({
async start() {
// Resolve symlinks to write through them (preserves the symlink)
try {
resolvedPath = await fsPromises.realpath(filePath);
} catch {
// If file doesn't exist, use the original path
resolvedPath = filePath;
}

// 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<Uint8Array>;
writer = webStream.getWriter();
Expand All @@ -261,7 +270,7 @@ export class LocalRuntime implements Runtime {
// Close the writer and rename to final location
await writer.close();
try {
await fsPromises.rename(tempPath, filePath);
await fsPromises.rename(tempPath, resolvedPath);
} catch (err) {
throw new RuntimeErrorClass(
`Failed to write file ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
Expand Down
4 changes: 3 additions & 1 deletion src/runtime/SSHRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,12 +263,14 @@ export class SSHRuntime implements Runtime {

/**
* Write file contents over SSH atomically from a stream
* Preserves symlinks by writing through them to the target file
*/
writeFile(path: string): WritableStream<Uint8Array> {
const tempPath = `${path}.tmp.${Date.now()}`;
// Create parent directory if needed, then write file atomically
// Use cat to copy through symlinks (preserves them), then chmod and mv
// 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 = `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)}`;

// Need to get the exec stream in async callbacks
let execPromise: Promise<ExecStream> | null = null;
Expand Down
43 changes: 43 additions & 0 deletions tests/runtime/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,49 @@ 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");
});
});

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