moeru-ai/airi · error · Error

Extension source must be a regular directory

Error message

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

What it means

inspectExtensionDirectory rejects any source path that is a symbolic link or not a directory, checked via lstat before any traversal. Extension packages must be plain, self-contained folders so that the entry walk, size accounting, and symlink prohibition cannot be bypassed or confused by links.

Solutions

  1. Select the real, physical directory, not a symlink to it.
  2. Extract the archive first and import the extracted folder, not the .zip file.
  3. Resolve or replace symlinks: copy -rL the source to a real directory and import that.
  4. Confirm the path is a directory: `ls -ld <path>` should show `d...` with no leading `l`.

Example fix

// before
await inspected('/opt/extensions/latest') // 'latest' is a symlink -> throws
// after
await inspected(await realpath('/opt/extensions/latest'))
Defensive patterns

Strategy: validation

Validate before calling

import { lstat, realpath } from 'node:fs/promises'
async function assertImportableDirectory(path: string) {
  const stats = await lstat(path)
  if (stats.isSymbolicLink() || !stats.isDirectory())
    throw new Error(`${path} must be a real directory, not a symlink or file.`)
  return realpath(path)
}

Try / catch

try {
  await inspected(path)
} catch (error) {
  if (error instanceof Error && error.message.startsWith('Extension source must be a regular directory')) {
    showUserError('Select the extracted extension folder itself, not a link or file.')
    return
  }
  throw error
}

Prevention

When it happens

Trigger: Calling inspectExtensionDirectory (via `inspected` or `staged`) with a path that resolves to a symlink to a directory, a regular file, a FIFO/socket/device, or a missing path (lstat ENOENT fails earlier, but dangling symlinks land here).

Common situations: User drags a symlink like `my-extension-latest -> my-extension-1.2.0` into the import dialog; user selects a zip file instead of an extracted folder; a path inside a symlinked project directory is picked; selecting the manifest file itself instead of its folder.

Related errors


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

Appendix: source

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

      throw new Error('Extension manifest exceeds the 1 MiB size limit.')
    }

    const finalStats = await handle.stat()
    if (!finalStats.isFile() || finalStats.size !== expectedSize || bytesRead !== expectedSize) {
      throw new Error('Extension package changed during inspection. Select the folder again.')
    }

    return buffer.subarray(0, bytesRead)
  }
  finally {
    await handle.close()
  }
}

async function inspectExtensionDirectory(sourcePath: string): Promise<InspectedExtensionDirectory> {
  const sourceStats = await lstat(sourcePath)
  if (sourceStats.isSymbolicLink() || !sourceStats.isDirectory()) {
    throw new Error(`Extension source must be a regular directory: ${sourcePath}`)
  }

  const sourceRealPath = await realpath(sourcePath)
  const manifestPath = join(sourceRealPath, extensionManifestFileName)
  await assertRegularFile(manifestPath, 'Extension manifest')

  const files: Array<{ path: string, relativePath: string, size: number }> = []
  const directories = ['.']
  const pendingDirectories = [sourceRealPath]
  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) {

View on GitHub (pinned to 438a067dde)