moeru-ai/airi · error · Error

Extension packages cannot contain symbolic links

Error message

Extension packages cannot contain symbolic links: ${relativePath}

What it means

Every entry is checked with lstat; if any entry (at any depth) is a symbolic link the walk aborts. Symbolic links are forbidden because they can point outside the package root, breaking the containment guarantee for path traversal, size accounting, and staged copying.

Solutions

  1. Replace symlinks with real copies of the target files/directories (cp -rL or rsync -L).
  2. Import a flattened/resolved copy of the folder rather than the original tree.
  3. Remove .bin or tool-generated symlink directories from the package.
  4. If the link target is needed, vendor the actual files into the extension folder.

Example fix

// before
await staged('my-ext/') // contains my-ext/assets -> ../shared/assets (symlink) -> throws
// after: dereference links into real files
// cp -rL my-ext my-ext-flat
await staged('my-ext-flat/')
Defensive patterns

Strategy: validation

Validate before calling

import { readdir, lstat } from 'node:fs/promises'
import { join } from 'node:path'
async function hasSymlinks(dir: string): Promise<boolean> {
  for (const entry of await readdir(dir, { withFileTypes: true })) {
    if (entry.isSymbolicLink()) return true
    if (entry.isDirectory() && await hasSymlinks(join(dir, entry.name))) return true
  }
  return false
}

Try / catch

try {
  await staged(folder)
} catch (error) {
  if (error instanceof Error && error.message.includes('symbolic links')) {
    showUserError(`Replace symlink with a real copy: ${error.message}`)
    return
  }
  throw error
}

Prevention

When it happens

Trigger: Calling inspectExtensionDirectory on a folder that contains any symlink — internal ones (entry -> ../shared) or absolute ones (entry -> /etc) — discovered during the recursive walk.

Common situations: macOS/Linux developers committing symlinks into the package (shared assets, node_modules .bin links); unpacking an archive containing symlinks; build tools creating .bin symlink directories inside the output.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


AI-assisted analysis of moeru-ai/airi@438a067dde (2026-09-17). Data as JSON: /api/errors/354a3aa4a2e7cd2a. Report an issue: GitHub.

Appendix: source

Thrown at apps/stage-tamagotchi/src/main/services/airi/plugins/host/directory-import.ts:183

  let entryCount = 0
  let totalBytes = 0
  while (pendingDirectories.length > 0) {
    const directory = pendingDirectories.pop()
    if (!directory) {
      continue
    }
    const entries = await opendir(directory)
    for await (const entry of entries) {
      entryCount += 1
      if (entryCount > extensionPackageLimits.entries) {
        throw new Error(`Extension package exceeds the ${extensionPackageLimits.entries} entry limit.`)
      }
      const path = join(directory, entry.name)
      const stats = await lstat(path)
      const relativePath = relative(sourceRealPath, path)

      if (stats.isSymbolicLink()) {
        throw new Error(`Extension packages cannot contain symbolic links: ${relativePath}`)
      }
      if (stats.isDirectory()) {
        directories.push(relativePath)
        pendingDirectories.push(path)
        continue
      }
      if (!stats.isFile()) {
        throw new Error(`Extension packages can contain only files and directories: ${relativePath}`)
      }
      totalBytes += stats.size
      if (totalBytes > extensionPackageLimits.totalBytes) {
        throw new Error('Extension package exceeds the 512 MiB size limit.')
      }
      files.push({ path, relativePath, size: stats.size })
    }
  }

  directories.sort()

View on GitHub (pinned to 438a067dde)