paperclipai/paperclip · error · Error

Refusing to replace unsafe service definition ${filePath}.

Error message

Refusing to replace unsafe service definition ${filePath}.

What it means

Thrown by writeIfChanged when the existing service definition file (e.g., paperclipai.service or the launchd plist) is not a regular file, is a symbolic link, or has a hard link count greater than 1. This triple check (regular-file + not-symlink + nlink<=1) prevents replacing an attacker-controlled symlink or a multiply-linked file that could cause the atomic rename to affect other paths.

Source

Thrown at cli/src/services/service-manager.ts:147

  <key>ThrottleInterval</key><integer>5</integer>
  <key>ExitTimeOut</key><integer>300</integer>
  <key>StandardOutPath</key><string>${escapeXml(input.stdoutPath)}</string>
  <key>StandardErrorPath</key><string>${escapeXml(input.stderrPath)}</string>
</dict>
</plist>
`;
}

async function writeIfChanged(filePath: string, contents: string): Promise<boolean> {
  const directoryPath = path.dirname(filePath);
  await fs.mkdir(directoryPath, { recursive: true, mode: 0o700 });
  const directoryStat = await fs.lstat(directoryPath);
  if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error(`Refusing to write service definition through unsafe directory ${directoryPath}.`);
  const currentUid = process.getuid?.();
  if (currentUid !== undefined && directoryStat.uid !== currentUid) throw new Error(`Refusing to write service definition in directory not owned by the current user: ${directoryPath}.`);
  try {
    const stat = await fs.lstat(filePath);
    if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink > 1) throw new Error(`Refusing to replace unsafe service definition ${filePath}.`);
    if (currentUid !== undefined && stat.uid !== currentUid) throw new Error(`Refusing to replace service definition not owned by the current user: ${filePath}.`);
    if (await fs.readFile(filePath, "utf8") === contents) return false;
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
  }
  const temporaryPath = path.join(directoryPath, `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}`);
  try {
    await fs.writeFile(temporaryPath, contents, { encoding: "utf8", mode: 0o644, flag: "wx" });
    await fs.rename(temporaryPath, filePath);
  } finally {
    await fs.rm(temporaryPath, { force: true });
  }
  return true;
}

export class SystemdServiceManager implements ServiceManager {
  readonly platform = "systemd" as const;
  readonly serviceName: string;

View on GitHub (pinned to 67001ec6eb)

Solutions

  1. Inspect the service definition file: 'ls -la ~/.config/systemd/user/paperclipai.service' (systemd) or the plist path (launchd).
  2. If it is a symlink, remove it: 'rm ~/.config/systemd/user/paperclipai.service'.
  3. If it is hard-linked (nlink > 1), remove the extra links or delete the file so the installer can recreate it.
  4. Retry the service install command.

Example fix

// before: service file is a symlink
// ls -la ~/.config/systemd/user/paperclipai.service -> lrwxrwxrwx

// after: remove symlink
// rm ~/.config/systemd/user/paperclipai.service
// re-run: paperclipai service install
Defensive patterns

Strategy: validation

Validate before calling

import fs from 'node:fs';

function isSafeServiceDefinition(filePath: string): boolean {
  try {
    const stat = fs.lstatSync(filePath);
    return stat.isFile() && !stat.isSymbolicLink() && stat.nlink <= 1;
  } catch (error) {
    return (error as NodeJS.ErrnoException).code === 'ENOENT';
  }
}

// Call before service install:
if (!isSafeServiceDefinition(manager.definitionPath)) {
  console.warn('Service definition is unsafe (symlink/hardlinked); removing before reinstall.');
  fs.rmSync(manager.definitionPath, { force: true });
}

Try / catch

try {
  await manager.install({ startNow: true, startOnLogin: true });
} catch (error) {
  if (error instanceof Error && error.message.includes('unsafe service definition')) {
    // Remove the unsafe file and retry
    await fs.rm(manager.definitionPath, { force: true });
    await manager.install({ startNow: true, startOnLogin: true });
  } else {
    throw error;
  }
}

Prevention

When it happens

Trigger: Calling install/start/restart on a service manager when the service definition file already exists as a symlink, a non-regular file, or a hard-linked file.

Common situations: A symlink attack placed a symlink at the service definition path. A dotfile manager symlinks the service file. The service file was manually hard-linked to another location for backup.

Related errors


AI-assisted analysis of paperclipai/paperclip@67001ec6eb (2026-08-12). Data as JSON: /api/errors/fb24c5ba91ea0570. Report an issue: GitHub.