fix: skip symlinks in makeTreeWritable to prevent EPERM on NixOS/nix-darwin (#1303)

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
This commit is contained in:
Tom Boucher 2026-03-18 21:15:33 -04:00 committed by GitHub
parent 2a2056bcd7
commit 150575957d

View file

@ -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) {