moeru-ai/airi · error · Error

Only screenshots from this AIRI computer-use store can be…

Error message

Only screenshots from this AIRI computer-use store can be read.

What it means

readImage() resolves both the store root and the requested path with realpath and allows reads only for files that resolve strictly inside the AIRI computer-use store directory. This is a symlink-escape/path-traversal guard: paths outside the store, the root itself, or paths reached via symlink are rejected.

Solutions

  1. Read only screenshot files the CLI wrote under app.getPath('userData')/computer-use (the configured storeRoot)
  2. Capture the path returned by the screenshot command instead of constructing your own
  3. Verify the resolved (realpath) location of the file is inside the store before calling readImage

Example fix

// before
await readImage({ path: '/Users/me/Pictures/shot.png' })
// after
const { artifactPath } = await run({ argv: ['invoke', 'screen.capture'] })
await readImage({ path: artifactPath })
Defensive patterns

Strategy: validation

Validate before calling

import { realpath, stat } from 'node:fs/promises'
import { relative, isAbsolute, sep } from 'node:path'
async function isInsideStore(root: string, p: string): Promise<boolean> {
  const [r, t] = await Promise.all([realpath(root), realpath(p)])
  const rel = relative(r, t)
  return Boolean(rel) && !isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`)
}

Try / catch

try {
  return await readImage({ path })
} catch (e) {
  if (e instanceof Error && e.message.includes('Only screenshots from this AIRI computer-use store')) {
    console.warn('Path escapes the store; capture via the CLI to get a valid artifact path')
    return null
  }
  throw e
}

Prevention

When it happens

Trigger: Passing a path outside options.storeRoot, passing the store root itself, a path containing '..' that escapes the root, or a symlink inside the store pointing to an external file.

Common situations: Trying to reuse readImage as a generic image reader; the CLI writing screenshots to a different store root than the host configured; swapped symlinks on the filesystem.

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

Appendix: source

Thrown at apps/stage-tamagotchi/src/main/services/airi/computer-use/runtime.ts:143

      let output: unknown = stdout.trim()
      try {
        output = JSON.parse(stdout)
      }
      catch {
        // Help output is text. Keep it intact for command discovery.
      }
      return { argv, exitCode, output, stderr }
    })
  }

  async function readImage(input: unknown): Promise<string> {
    const { path } = v.parse(v.object({ path: v.string() }), input)
    return enqueue(async () => {
      const root = await realpath(options.storeRoot)
      const target = await realpath(path)
      const within = relative(root, target)
      if (!within || isAbsolute(within) || within === '..' || within.startsWith(`..${sep}`))
        throw new Error('Only screenshots from this AIRI computer-use store can be read.')
      const info = await stat(target)
      if (!info.isFile() || info.size > 8 * 1024 * 1024)
        throw new Error('Screenshot must be a file smaller than 8 MiB.')
      const data = await readFile(target)
      const png = data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
      const jpeg = data[0] === 255 && data[1] === 216 && data[2] === 255
      if (!png && !jpeg)
        throw new Error('Screenshot must be PNG or JPEG.')
      return `data:image/${png ? 'png' : 'jpeg'};base64,${data.toString('base64')}`
    })
  }

  /** Rejects queued calls, aborts active work, and waits for the owned daemon to exit. */
  async function dispose() {
    disposed = true
    abort.abort()
    await queue
    try {

View on GitHub (pinned to 438a067dde)