can1357/oh-my-pi · error

skill:// path resolves outside the plugin root: ${url.href}

Error message

skill:// path resolves outside the plugin root: ${url.href}

What it means

For Agent Plugin skills (spec §4.1) that declare a containRoot, resolve() additionally verifies the requested path canonically resolves (via resolveContainedPath, which follows symlinks) inside the plugin's contain root. If the real path lands outside that root, resolve() fails closed with this error that includes the offending URL. Symlinks may only target files within the same plugin package.

Source

Thrown at packages/coding-agent/src/internal-urls/skill-protocol.ts:86

		const hasRelativePath = urlPath && urlPath !== "/" && urlPath !== "";

		if (hasRelativePath) {
			const relativePath = decodeURIComponent(urlPath.slice(1));
			validateRelativePath(relativePath);
			targetPath = path.join(skill.baseDir, relativePath);

			const resolvedPath = path.resolve(targetPath);
			const resolvedBaseDir = path.resolve(skill.baseDir);
			if (!resolvedPath.startsWith(resolvedBaseDir + path.sep) && resolvedPath !== resolvedBaseDir) {
				throw new Error("Path traversal is not allowed");
			}
			// Agent Plugin skills (§4.1): the resource must canonically resolve
			// within the plugin root; a dangling or unresolvable path fails closed.
			// Symlinks may target other files inside the same package.
			if (skill.containRoot) {
				const contained = await resolveContainedPath(skill.containRoot, resolvedPath);
				if (contained.status === "outside") {
					throw new Error(`skill:// path resolves outside the plugin root: ${url.href}`);
				}
				if (contained.status === "missing") {
					throw new Error(`File not found: ${resolvedPath}`);
				}
				targetPath = contained.realPath;
			}
		} else {
			targetPath = context?.pathOnly === true ? skill.baseDir : skill.filePath;
		}

		let stats: fsTypes.Stats;
		try {
			stats = await fs.stat(targetPath);
		} catch (error) {
			if (isEnoent(error)) {
				throw new Error(`File not found: ${targetPath}`);
			}
			throw error;

View on GitHub (pinned to 9690622007)

Solutions

  1. Restructure the plugin so all skill-referenced files (including symlink targets) live inside the plugin contain root
  2. Replace out-of-package symlinks with copies of the files inside the plugin package
  3. If the resource belongs to another plugin/skill, reference it via that skill's own skill:// URL
  4. Check the plugin layout on disk (fs.realpath on the target) to confirm where the canonical path lands

Example fix

// before
// plugin/skills/my-skill/shared.md -> ../../../shared/docs.md (outside plugin root)
// after
cp ../../../shared/docs.md plugin/skills/my-skill/shared.md  # physical copy inside contain root
Defensive patterns

Strategy: validation

Validate before calling

import { resolveContainedPath } from '../discovery/contained-path';
const target = path.resolve(skill.baseDir, rel);
const contained = await resolveContainedPath(skill.containRoot, target);
if (contained.status !== 'inside') {
  throw new Error(`target resolves outside plugin root (${contained.status})`);
}

Try / catch

try {
  return await handler.resolve(url, ctx);
} catch (err) {
  if (err instanceof Error && err.message.includes('resolves outside the plugin root')) {
    // flag the plugin for repackaging; skip resolving this path
  }
  throw err;
}

Prevention

When it happens

Trigger: Resolving skill://<name>/<path> on a plugin skill where the path (or a symlink along it) resolves via fs.realpath to a location outside skill.containRoot — e.g. a symlink to a shared directory elsewhere on disk, or a path that escapes the plugin package after canonicalization.

Common situations: Plugin authors symlinking shared assets from outside the package (e.g. ../../shared/theme.md) into their skill; linking to node_modules outside the contain root; a moved or re-rooted plugin install where symlinks now point outside; build artifacts generated outside the plugin root and linked in.

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/69f852d3c3e51ea9. Report an issue: GitHub.