moeru-ai/airi · error

Extension source must be a regular directory

Error message

Extension source must be a regular directory: ${sourceRoot}

What it means

copyExtensionDirectory refuses to copy a plugin source whose root is a symlink or not a directory. The extension host treats untrusted package sources as attack surfaces, so only plain directories are accepted for import. This check runs before any bytes are copied via commitPreparedPlan.

Solutions

  1. Pass the real unpacked extension directory, not a symlink to it (resolve it first with fs.realpath).
  2. Ensure the path points to the extension root directory containing the manifest, not a file or archive.
  3. If importing from /tmp or another symlinked location, resolve the canonical path before calling the import API.

Example fix

// before
await importExtension('/tmp/airi-plugin-link')
// after
const real = await fs.realpath('/tmp/airi-plugin-link')
const stat = await fs.lstat(real)
if (stat.isDirectory()) await importExtension(real)
Defensive patterns

Strategy: validation

Validate before calling

import { lstat, realpath } from 'node:fs/promises'
async function canImportExtensionSource(sourceRoot: string): Promise<boolean> {
  const real = await realpath(sourceRoot)
  const stats = await lstat(real)
  return !stats.isSymbolicLink() && stats.isDirectory()
}

Type guard

function isRegularDirectory(stats: { isSymbolicLink(): boolean, isDirectory(): boolean }): boolean {
  return !stats.isSymbolicLink() && stats.isDirectory()
}

Try / catch

try {
  await importExtension(sourceRoot)
}
catch (error) {
  if (errorMessageFrom(error)?.startsWith('Extension source must be a regular directory')) {
    // resolve symlink / pick the real directory and retry once
  }
}

Prevention

When it happens

Trigger: Passing a path that is a symbolic link, a regular file, a FIFO, a device node, or a socket as sourceRoot to copyExtensionDirectory (indirectly via commitPreparedPlan when installing/importing a directory-based extension).

Common situations: User drags a symlinked plugin folder from another disk; a build step leaves a zip-extracted file instead of a directory; the source path points to a single .js file rather than an unpacked extension folder; OS-level temp dirs resolve through symlinks (/tmp on macOS).

Related errors


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

Appendix: source

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

      }
    }
    fingerprint.update('\0')
  }

  return {
    sourcePath: sourceRealPath,
    manifest: parsedManifest.manifest,
    fileCount: files.length,
    totalBytes,
    fingerprint: fingerprint.digest('hex'),
  }
}

/** Copies an untrusted package while enforcing the same resource limits as inspection. */
async function copyExtensionDirectory(sourceRoot: string, destinationRoot: string): Promise<void> {
  const sourceStats = await lstat(sourceRoot)
  if (sourceStats.isSymbolicLink() || !sourceStats.isDirectory()) {
    throw new Error(`Extension source must be a regular directory: ${sourceRoot}`)
  }

  await mkdir(destinationRoot)
  const pendingDirectories = [{ source: sourceRoot, destination: destinationRoot }]
  let entryCount = 0
  let totalBytes = 0

  while (pendingDirectories.length > 0) {
    const directory = pendingDirectories.pop()
    if (!directory) {
      continue
    }

    const entries = await opendir(directory.source)
    for await (const entry of entries) {
      entryCount += 1
      if (entryCount > extensionPackageLimits.entries) {
        throw new Error(`Extension package exceeds the ${extensionPackageLimits.entries} entry limit.`)

View on GitHub (pinned to 438a067dde)