stablyai/orca · error · Error

Invalid pet.json

Error message

Invalid pet.json: ${error instanceof Error ? error.message : 'parse error'}

What it means

Thrown when importing a pet bundle whose pet.json cannot be read, parsed as JSON, or validated against PetManifestSchema (Zod). It is a catch-all wrapper at pet.ts:278-281 that re-wraps any failure from readFile, JSON.parse, the MAX_MANIFEST_BYTES (64 KB) TOCTOU guard, or PetManifestSchema.parse into a single 'Invalid pet.json' message, appending the inner error's message when available. The inner cause is preserved in the message suffix, which is the real diagnostic.

Solutions

  1. Read the suffix after the colon — it is the inner Zod/error message and names the exact failing field (e.g. 'spritesheetPath: invalid spritesheetPath'). Fix that field in pet.json.
  2. Validate the manifest locally before import: const PetManifestSchema requires id/description/spritesheetPath bounds listed at pet.ts:77-113; run it through a JSON linter.
  3. Confirm pet.json is under 64 KB and contains no NUL bytes, leading '/', '\', or '..' segments in spritesheetPath.
  4. If the file was mid-write, re-export the bundle and retry the import.

Example fix

// before — pet.json with a trailing comma and oversized id
{
  "id": "<128+ chars>",
  "displayName": "Cat",
  "spritesheetPath": "cat.png",
}
// after — valid shape within Zod bounds
{
  "displayName": "Cat",
  "spritesheetPath": "cat.png",
  "frame": { "width": 32, "height": 32 }
}
Defensive patterns

Strategy: validation

Validate before calling

// Validate pet.json against the same shape before invoking the import IPC.
import { z } from 'zod'
const PetManifestSchema = z.object({
  id: z.string().min(1).max(128).optional(),
  displayName: z.string().min(1).max(120).optional(),
  description: z.string().max(2000).optional(),
  spritesheetPath: z.string().min(1).max(255)
    .refine((p) => !p.includes('\0') && !p.startsWith('/') && !p.startsWith('\\') && !p.includes('..'), 'invalid spritesheetPath')
    .optional(),
  frame: z.object({ width: z.number().int().positive().max(1024), height: z.number().int().positive().max(1024) }).optional(),
  fps: z.number().positive().max(60).optional(),
  defaultAnimation: z.string().min(1).max(64).optional(),
  animations: z.record(z.string().min(1).max(64), z.object({
    row: z.number().int().min(0).max(256),
    frames: z.number().int().positive().max(512),
    frameDurationsMs: z.array(z.number().positive().max(60_000)).max(512).optional()
  })).optional()
}).loose()

async function preflightManifest(path: string) {
  const raw = await readFile(path, 'utf8')
  if (Buffer.byteLength(raw, 'utf8') > 64 * 1024) throw new Error('manifest too large')
  return PetManifestSchema.parse(JSON.parse(raw))
}

Type guard

function isPetManifest(v: unknown): v is PetManifest {
  return typeof v === 'object' && v !== null &&
    (v.spritesheetPath === undefined || (typeof v.spritesheetPath === 'string' && v.spritesheetPath.length <= 255))
}

Try / catch

try {
  await importPetBundle(pickedPath)
} catch (e) {
  if (e instanceof Error && e.message.startsWith('Invalid pet.json:')) {
    // surface the inner cause (after the colon) to the user for the specific field fix
    showError(e.message.slice('Invalid pet.json:'.length).trim())
  } else throw e
}

Prevention

When it happens

Trigger: Calling the pet:import IPC handler (or whichever path invokes the bundle-import function) with a directory whose pet.json is truncated/malformed JSON, references unknown keys in strict mode, has a field outside Zod bounds (e.g. id longer than 128 chars, spritesheetPath with a NUL byte or '..'), or a raw file larger than MAX_MANIFEST_BYTES (64*1024) that grew between the earlier stat check and the readFile at line 273.

Common situations: Hand-edited pet.json with a trailing comma or unquoted value; bundle exported by a generator emitting a field the schema doesn't expect combined with a future schema tightening; manifest accidentally including megabytes of base64 metadata; encoding issue (UTF-16 BOM) making JSON.parse throw; a partially written file from a crashed export.

Understand the failure class

Related errors


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

Appendix: source

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

      throw new Error('Bundle is missing pet.json.')
    }
    if (!manifestStat.isFile() || manifestStat.size > MAX_MANIFEST_BYTES) {
      throw new Error('pet.json is invalid.')
    }
    if (await isSymlink(manifestPath)) {
      throw new Error('pet.json must not be a symlink.')
    }

    let manifest: ResolvedPetManifest<PetManifest>
    try {
      const raw = await readFile(manifestPath, 'utf8')
      // Why: defend against TOCTOU — the file may have grown between the stat check and this read.
      if (Buffer.byteLength(raw, 'utf8') > MAX_MANIFEST_BYTES) {
        throw new Error('pet.json exceeded the manifest size limit.')
      }
      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

View on GitHub (pinned to 1136503c6a)