From 150575957d048a2113058e8a78b514d1a8b68295 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 18 Mar 2026 21:15:33 -0400 Subject: [PATCH] fix: skip symlinks in makeTreeWritable to prevent EPERM on NixOS/nix-darwin (#1303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit makeTreeWritable used statSync which follows symlinks. On NixOS and nix-darwin, ~/.gsd/agent/bin/ contains symlinks to the immutable Nix store (/run/current-system/sw/bin/). Attempting to chmod those targets crashed GSD on startup with EPERM. Changes: - Use lstatSync instead of statSync — detects symlinks without following them - Skip symlinks entirely (they don't carry own permissions, targets may be immutable) - Added try/catch around chmodSync as safety net for any remaining permission errors on unusual filesystems Secondary analysis: rmSync with force:true already handles symlinks correctly (removes the link, not the target). cpSync with force:true replaces symlinks with regular files (desired behavior for resource sync). Fixes #1298 --- src/resource-loader.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/resource-loader.ts b/src/resource-loader.ts index 103ed6a01..bcae127d0 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -1,7 +1,7 @@ import { DefaultResourceLoader } from '@gsd/pi-coding-agent' import { createHash } from 'node:crypto' import { homedir } from 'node:os' -import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { compareSemver } from './update-check.js' @@ -133,7 +133,12 @@ export function getNewerManagedResourceVersion(agentDir: string, currentVersion: function makeTreeWritable(dirPath: string): void { if (!existsSync(dirPath)) return - const stats = statSync(dirPath) + // Use lstatSync to avoid following symlinks into immutable filesystems + // (e.g., Nix store on NixOS/nix-darwin). Symlinks don't carry their own + // permissions and their targets may be read-only by design (#1298). + const stats = lstatSync(dirPath) + if (stats.isSymbolicLink()) return + const isDir = stats.isDirectory() const currentMode = stats.mode & 0o777 @@ -144,7 +149,11 @@ function makeTreeWritable(dirPath: string): void { } if (newMode !== currentMode) { - chmodSync(dirPath, newMode) + try { + chmodSync(dirPath, newMode) + } catch { + // Non-fatal — may fail on read-only filesystems or insufficient permissions + } } if (isDir) {