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
- Pass the real unpacked extension directory, not a symlink to it (resolve it first with fs.realpath).
- Ensure the path points to the extension root directory containing the manifest, not a file or archive.
- 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
- Always fs.realpath() user-supplied paths before import.
- Reject file and archive paths at the UI layer before reaching the importer.
- On macOS, remember /tmp is a symlink to /private/tmp; resolve before use.
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
- Extension packages can contain only files and directories
- Extension package exceeds the
- Extension packages cannot contain symbolic links
- Extension source must be a regular directory
- must be a regular file
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)