{"record":{"id":"cff3e78179f431e2","repo":"moeru-ai/airi","slug":"extension-source-must-be-a-regular-directory-sourceroot","errorCode":null,"errorMessage":"Extension source must be a regular directory: ${sourceRoot}","messagePattern":"Extension source must be a regular directory: (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"apps/stage-tamagotchi/src/main/services/airi/plugins/host/directory-import.ts","lineNumber":291,"sourceCode":"      }\n    }\n    fingerprint.update('\\0')\n  }\n\n  return {\n    sourcePath: sourceRealPath,\n    manifest: parsedManifest.manifest,\n    fileCount: files.length,\n    totalBytes,\n    fingerprint: fingerprint.digest('hex'),\n  }\n}\n\n/** Copies an untrusted package while enforcing the same resource limits as inspection. */\nasync function copyExtensionDirectory(sourceRoot: string, destinationRoot: string): Promise<void> {\n  const sourceStats = await lstat(sourceRoot)\n  if (sourceStats.isSymbolicLink() || !sourceStats.isDirectory()) {\n    throw new Error(`Extension source must be a regular directory: ${sourceRoot}`)\n  }\n\n  await mkdir(destinationRoot)\n  const pendingDirectories = [{ source: sourceRoot, destination: destinationRoot }]\n  let entryCount = 0\n  let totalBytes = 0\n\n  while (pendingDirectories.length > 0) {\n    const directory = pendingDirectories.pop()\n    if (!directory) {\n      continue\n    }\n\n    const entries = await opendir(directory.source)\n    for await (const entry of entries) {\n      entryCount += 1\n      if (entryCount > extensionPackageLimits.entries) {\n        throw new Error(`Extension package exceeds the ${extensionPackageLimits.entries} entry limit.`)","sourceCodeStart":273,"sourceCodeEnd":309,"githubUrl":"https://github.com/moeru-ai/airi/blob/438a067dde47aa0bdb46c2323d1fe293dc805218/apps/stage-tamagotchi/src/main/services/airi/plugins/host/directory-import.ts#L273-L309","documentation":"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.","triggerScenarios":"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).","commonSituations":"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).","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."],"exampleFix":"// before\nawait importExtension('/tmp/airi-plugin-link')\n// after\nconst real = await fs.realpath('/tmp/airi-plugin-link')\nconst stat = await fs.lstat(real)\nif (stat.isDirectory()) await importExtension(real)","handlingStrategy":"validation","validationCode":"import { lstat, realpath } from 'node:fs/promises'\nasync function canImportExtensionSource(sourceRoot: string): Promise<boolean> {\n  const real = await realpath(sourceRoot)\n  const stats = await lstat(real)\n  return !stats.isSymbolicLink() && stats.isDirectory()\n}","typeGuard":"function isRegularDirectory(stats: { isSymbolicLink(): boolean, isDirectory(): boolean }): boolean {\n  return !stats.isSymbolicLink() && stats.isDirectory()\n}","tryCatchPattern":"try {\n  await importExtension(sourceRoot)\n}\ncatch (error) {\n  if (errorMessageFrom(error)?.startsWith('Extension source must be a regular directory')) {\n    // resolve symlink / pick the real directory and retry once\n  }\n}","preventionTips":["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."],"tags":["filesystem","security","validation"],"backgroundTag":"path-is-not-a-directory","analyzedSha":"438a067dde47aa0bdb46c2323d1fe293dc805218","analyzedAt":"2026-09-17T01:14:42.644Z","contentChangedAt":"2026-09-17T01:14:42.644Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}