stablyai/orca · error · Error

spritesheetPath must point to a file, not the bundle root.

Error message

spritesheetPath must point to a file, not the bundle root.

What it means

Thrown at pet.ts:295-296 when resolve(bundleDir, normalizedSpritePath) equals resolve(bundleDir) — meaning spritesheetPath resolves to the bundle directory itself (empty or all-separator value), not a file. This guards the later directory-walking and copy logic from being pointed at a directory root.

Source

Thrown at src/main/ipc/pet.ts:296

      manifest = applyCodexPetDefaults(PetManifestSchema.parse(JSON.parse(raw)))
    } catch (error) {
      throw new Error(`Invalid pet.json: ${error instanceof Error ? error.message : 'parse error'}`)
    }

    // Why: spritesheetPath is bundle-relative and attacker-controlled — reject absolute/escaping paths (and symlinks) so a bundle can't reach outside.
    const normalizedSpritePath = manifest.spritesheetPath.replace(/[\\/]+/g, sep)
    if (
      isAbsolute(manifest.spritesheetPath) ||
      isAbsolute(normalizedSpritePath) ||
      /^[a-zA-Z]:/.test(manifest.spritesheetPath)
    ) {
      throw new Error('spritesheetPath must be relative to the bundle.')
    }
    // Why: bundles exported on Windows may be imported on macOS/Linux; normalize separators before resolving.
    const sheetSrc = resolve(bundleDir, normalizedSpritePath)
    const bundleResolved = resolve(bundleDir)
    if (sheetSrc === bundleResolved) {
      throw new Error('spritesheetPath must point to a file, not the bundle root.')
    }
    const bundleRoot = bundleResolved + sep
    // Why: Windows volumes are case-insensitive; lowercase the prefix compare so case differences can't bypass the escape check.
    const cmp = process.platform === 'win32' ? (s: string) => s.toLowerCase() : (s: string) => s
    if (!cmp(sheetSrc + sep).startsWith(cmp(bundleRoot))) {
      throw new Error('spritesheetPath escapes the bundle.')
    }
    if (await isSymlink(sheetSrc)) {
      throw new Error('spritesheet must not be a symlink.')
    }
    const sheetClass = classifyFile(sheetSrc)
    if (!sheetClass || sheetClass.ext === '.svg') {
      // SVG can't be used as a sprite sheet (no pixel grid).
      throw new Error('Spritesheet must be a PNG, APNG, JPG, GIF, or WebP.')
    }
    let sheetStat: Awaited<ReturnType<typeof stat>>
    try {
      sheetStat = await stat(sheetSrc)

View on GitHub (pinned to 1136503c6a)

Solutions

  1. Set spritesheetPath to the actual image filename inside the bundle, e.g. "spritesheet.png".
  2. Ensure the value is neither empty nor '.' and points to a real file inside the bundle directory.

Example fix

// before
{ "spritesheetPath": "." }
// after
{ "spritesheetPath": "spritesheet.png" }
Defensive patterns

Strategy: validation

Validate before calling

import { resolve } from 'node:path'
function assertSheetIsNotBundleRoot(bundleDir: string, spritePath: string) {
  const normalized = spritePath.replace(/[\\/]+/g, '/')
  if (resolve(bundleDir, normalized) === resolve(bundleDir)) {
    throw new Error('spritesheetPath resolves to the bundle root')
  }
}

Try / catch

try { await importPetBundle(p) }
catch (e) { if (e instanceof Error && e.message === 'spritesheetPath must point to a file, not the bundle root.') { /* fix manifest */ } else throw e }

Prevention

When it happens

Trigger: pet.json sets spritesheetPath to '.', '', '.\', or a value that normalizes away to nothing so that resolve collapses it to bundleDir. Also reachable if the path is all slashes.

Common situations: Bundle generator emits an empty spritesheetPath default; a hand-edit sets '.' by mistake; the field was trimmed to empty.

Related errors


AI-assisted analysis of stablyai/orca@1136503c6a (2026-08-12). Data as JSON: /api/errors/fab0077810260081. Report an issue: GitHub.