{"record":{"id":"c27f1a63add8909d","repo":"moeru-ai/airi","slug":"screenshot-must-be-png-or-jpeg","errorCode":null,"errorMessage":"Screenshot must be PNG or JPEG.","messagePattern":"Screenshot must be PNG or JPEG\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"apps/stage-tamagotchi/src/main/services/airi/computer-use/runtime.ts","lineNumber":151,"sourceCode":"    })\n  }\n\n  async function readImage(input: unknown): Promise<string> {\n    const { path } = v.parse(v.object({ path: v.string() }), input)\n    return enqueue(async () => {\n      const root = await realpath(options.storeRoot)\n      const target = await realpath(path)\n      const within = relative(root, target)\n      if (!within || isAbsolute(within) || within === '..' || within.startsWith(`..${sep}`))\n        throw new Error('Only screenshots from this AIRI computer-use store can be read.')\n      const info = await stat(target)\n      if (!info.isFile() || info.size > 8 * 1024 * 1024)\n        throw new Error('Screenshot must be a file smaller than 8 MiB.')\n      const data = await readFile(target)\n      const png = data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))\n      const jpeg = data[0] === 255 && data[1] === 216 && data[2] === 255\n      if (!png && !jpeg)\n        throw new Error('Screenshot must be PNG or JPEG.')\n      return `data:image/${png ? 'png' : 'jpeg'};base64,${data.toString('base64')}`\n    })\n  }\n\n  /** Rejects queued calls, aborts active work, and waits for the owned daemon to exit. */\n  async function dispose() {\n    disposed = true\n    abort.abort()\n    await queue\n    try {\n      await daemon?.stop()\n    }\n    finally {\n      await cleanupSocket()\n    }\n  }\n\n  return { run, readImage, dispose }","sourceCodeStart":133,"sourceCodeEnd":169,"githubUrl":"https://github.com/moeru-ai/airi/blob/438a067dde47aa0bdb46c2323d1fe293dc805218/apps/stage-tamagotchi/src/main/services/airi/computer-use/runtime.ts#L133-L169","documentation":"After reading the file, readImage() sniffs magic bytes: PNG (89 50 4E 47 0D 0A 1A 0A) or JPEG (FF D8 FF). Any other content is rejected because the returned data URL hardcodes image/png or image/jpeg and downstream consumers (model vision input) require those formats.","triggerScenarios":"The file inside the store is a WebP/BMP/GIF, an empty file, a partially written download, or a text/error file instead of a real screenshot.","commonSituations":"Another tool wrote into the store directory; a truncated capture from a crashed process; renaming a .webp to .png and expecting readImage to accept it.","solutions":["Let the AIRI CLI produce the screenshot (it emits PNG/JPEG) instead of placing your own file in the store","Convert the image to PNG or JPEG before calling readImage","Check the first bytes (PNG/JPEG magic) before calling to fail early"],"exampleFix":"// before\nawait copyFile('capture.webp', join(storeRoot, 'capture.png'))\nawait readImage({ path: join(storeRoot, 'capture.png') })\n// after\nconst jpegPath = await sharp('capture.webp').jpeg().toFile(join(storeRoot, 'capture.jpg'))\nawait readImage({ path: jpegPath })","handlingStrategy":"validation","validationCode":"import { readFile } from 'node:fs/promises'\nasync function isPngOrJpeg(p: string): Promise<boolean> {\n  const b = (await readFile(p)).subarray(0, 3)\n  const png = b[0] === 137 && b[1] === 80 && b[2] === 78\n  const jpeg = b[0] === 255 && b[1] === 216 && b[2] === 255\n  return png || jpeg\n}","typeGuard":"function hasImageMagic(bytes: Uint8Array): boolean {\n  const png = bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71\n  const jpeg = bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255\n  return png || jpeg\n}","tryCatchPattern":"try {\n  return await readImage({ path })\n} catch (e) {\n  if (e instanceof Error && e.message === 'Screenshot must be PNG or JPEG.') {\n    const converted = await convertToPng(path)\n    return readImage({ path: converted })\n  }\n  throw e\n}","preventionTips":["Only read files produced by the AIRI computer-use CLI","Sniff magic bytes before calling; do not trust file extensions","Convert WebP/BMP/GIF inputs to PNG/JPEG first"],"tags":["image-format","magic-bytes","validation"],"backgroundTag":"incompatible-source-type","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"}