moeru-ai/airi · error

Extension entrypoint resolves outside the package folder

Error message

Extension entrypoint resolves outside the package folder: ${entrypoint}

What it means

After the entrypoint path passes the lexical containment check, AIRI resolves the file's `realpath` (following symlinks) and verifies it again stays within the package folder. This error is thrown when the entrypoint file is a symlink (or hardlink chain) pointing outside the imported directory — a defense against symlink-based escapes from the package sandbox.

Solutions

  1. Replace the symlinked entrypoint with a real copy of the target file inside the package folder.
  2. Re-pack the extension ensuring no symlink entries escape the root (dereference symlinks when zipping, e.g. `zip -r --symlinks` alternatives or copy with `-L`).
  3. If the symlink target is legitimate, move that dependency into the package and update the manifest entrypoint.

Example fix

// before: package/main.js -> /home/user/lib/main.js (symlink)
// after
cp -L /home/user/lib/main.js package/main.js  # real file inside the package
Defensive patterns

Strategy: validation

Validate before calling

import { realpath } from 'node:fs/promises'
async function assertNoSymlinkEscape(root, entrypoint) {
  const real = await realpath(resolve(root, entrypoint))
  if (real !== root && !real.startsWith(root + '/'))
    throw new Error(`entrypoint symlink escapes package: ${entrypoint} -> ${real}`)
}

Type guard

import { lstat } from 'node:fs/promises'
const isRealFileNotSymlink = async (p) => (await lstat(p)).isFile() && !(await lstat(p)).isSymbolicLink()

Try / catch

try {
  await importExtension(folder)
} catch (err) {
  if (String(err.message).includes('resolves outside the package folder')) {
    showHint('The entrypoint is a symlink pointing outside the package. Ship real files instead.')
  } else throw err
}

Prevention

When it happens

Trigger: `inspectExtensionDirectory` calls `realpath(resolvedEntrypoint)` and the resulting real path is not contained by `sourceRealPath`, e.g. the entrypoint is `./main.js` but `main.js` is a symlink to `/home/user/lib/main.js` outside the package.

Common situations: Developers symlinking source files during development into the extension folder and then importing the folder as-is; package managers creating symlinks (e.g. pnpm/node_modules links) inside the extension; zips extracted with symlink entries preserved.

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/a495ff0a4565e155. Report an issue: GitHub.

Appendix: source

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

  if (!parsedManifest.success) {
    throw new Error(`Extension manifest is invalid: ${formatManifestDiagnostics(parsedManifest.diagnostics)}`)
  }

  for (const entrypoint of Object.values(parsedManifest.manifest.entrypoints)) {
    if (!entrypoint) {
      continue
    }
    if (isAbsolute(entrypoint)) {
      throw new Error(`Imported Extension entrypoints must be relative paths: ${entrypoint}`)
    }
    const resolvedEntrypoint = resolve(sourceRealPath, entrypoint)
    if (!isContainedPath(sourceRealPath, resolvedEntrypoint)) {
      throw new Error(`Extension entrypoint escapes the package folder: ${entrypoint}`)
    }
    await assertRegularFile(resolvedEntrypoint, 'Extension entrypoint')
    const entrypointRealPath = await realpath(resolvedEntrypoint)
    if (!isContainedPath(sourceRealPath, entrypointRealPath)) {
      throw new Error(`Extension entrypoint resolves outside the package folder: ${entrypoint}`)
    }
  }

  const fingerprint = createHash('sha256')
  for (const directory of directories) {
    fingerprint.update(`directory\0${directory}\0`)
  }
  for (const file of files) {
    fingerprint.update(`file\0${file.relativePath}\0${file.size}\0`)
    if (file.relativePath === manifestRelativePath) {
      fingerprint.update(manifestContents)
    }
    else {
      let bytesRead = 0
      for await (const chunk of createReadStream(file.path)) {
        const contents = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
        bytesRead += contents.byteLength
        if (bytesRead > file.size) {

View on GitHub (pinned to 438a067dde)