fix(imagegen): accept user-attached images as edit inputs

ImageEdit trusted only two per-session directories: the bridge upload dir
and its own generated-images dir. Neither is where a user's image actually
lands. An @-mentioned file keeps its original path anywhere on disk, and a
pasted or dropped image goes to ~/.claude/image-cache/<sessionId>/, so every
image a user supplied was refused.

The tool description made it worse by pointing the model at
[Image source: ...] paths, which are exactly the image-cache paths the check
then rejected. The model followed the description, got refused, and asked
users to re-attach a file they had just attached.

Record images the user names explicitly in a session-scoped registry, and
trust the paste directory by location. Paths the model found on its own — a
Glob hit, a path read out of a file — stay refused, which is what the
original root-dir check was protecting against.

Claude-Session: https://claude.ai/code/session_01ArehnJt4QLb83xkEukqNFX
This commit is contained in:
程序员阿江(Relakkes)
2026-08-23 02:12:39 +08:00
parent ba8a5cb832
commit ba859a28ae
10 changed files with 435 additions and 13 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ Use the built-in `ImageGen` and `ImageEdit` tools. Provider authentication, mode
- Treat a request that preserves, combines, or changes an existing visual as an edit and call `ImageEdit`.
- One distinct prompt equals one tool call.
- Use `count` only for multiple variations of the same prompt. For different concepts, make separate calls.
- `ImageEdit` requires `referenced_image_paths`: populate it with ordered, exact paths surfaced by `[Image source: ...]` in a user attachment or returned by an earlier `ImageGen` call. Never invent, search for, or substitute another filesystem path. The first image is the primary canvas unless the user says otherwise.
- `ImageEdit` requires `referenced_image_paths`: populate it with ordered, exact paths to images the user supplied in this conversation — a path surfaced by `[Image source: ...]`, a file the user attached with `@`, or a path returned by an earlier `ImageGen` call. Never invent, search for, or substitute another filesystem path, and never read an image off disk yourself to use it as an input; if the user means an image you have no path for, ask them to attach it. The first image is the primary canvas unless the user says otherwise.
- For multi-turn editing, use the latest selected output as the next turn's `edit_target`. Repeat all identity, layout, text, and unchanged-region constraints on every turn so edits do not drift.
- To edit several images independently, make one call per image. Put multiple images in one call only when the user wants them combined or used together as references. A single call accepts at most three source images.
- Prefer a useful default composition when the user leaves details open. Do not invent branding, logos, or people they did not request.
+13 -2
View File
@@ -151,7 +151,7 @@ describe('ImageGenTool', () => {
durationMs: 42,
}
expect(await ImageEditTool.description()).toContain('Edit images using exact source paths')
expect(await ImageEditTool.description()).toContain('attached with @')
expect(ImageEditTool.outputSchema.parse(output)).toEqual(output)
expect(ImageEditTool.isEnabled()).toBe(true)
expect(ImageEditTool.isConcurrencySafe()).toBe(true)
@@ -194,7 +194,18 @@ describe('ImageGenTool', () => {
const editPrompt = await ImageEditTool.prompt()
expect(editPrompt).toContain('referenced_image_paths is required')
expect(editPrompt).toContain('never invent, search for, or substitute a path')
expect(editPrompt).toContain('Never invent, search for, or substitute a path')
expect(editPrompt).toContain('do not retry ImageEdit automatically')
// The three sources the backend actually accepts. Listing fewer sends the
// model to ask the user to re-attach a file they already attached.
expect(editPrompt).toContain('[Image source: ...]')
expect(editPrompt).toContain('attached with @')
expect(editPrompt).toContain('prior ImageGen call')
// ...and the one it must not do: read an image off disk to feed it here.
expect(editPrompt).toContain('never open an image off disk')
const editPathDescription = ImageEditTool.inputSchema.shape
.referenced_image_paths.element.description
expect(editPathDescription).toContain('attached with @')
})
})
+3 -3
View File
@@ -74,7 +74,7 @@ const editInputSchema = lazySchema(() =>
.array(z
.string()
.min(1)
.describe('Exact absolute path from an [Image source: ...] attachment or a prior ImageGen result'))
.describe('Exact absolute path to an image the user provided this session: an [Image source: ...] attachment, a file the user attached with @, or a prior ImageGen result'))
.min(1)
.max(3)
.describe('Ordered source images to edit or use as visual references'),
@@ -176,10 +176,10 @@ export const ImageEditTool = buildTool({
strict: false,
shouldDefer: true,
async description() {
return 'Edit images using exact source paths from user attachments or earlier ImageGen results.'
return 'Edit images the user attached in this conversation — pasted, dropped, or attached with @ — or images returned by an earlier ImageGen call, using their exact source paths.'
},
async prompt() {
return 'Use this tool only when the user wants to edit, combine, or visually reference existing images. referenced_image_paths is required and may contain only exact paths surfaced by [Image source: ...] in the current conversation or returned by a prior ImageGen call; never invent, search for, or substitute a path. Preserve the full relevant user specification and repeat preservation constraints in every edit prompt. Provider and image model selection come from the current desktop session and are not tool arguments. One call represents one distinct prompt; use count only for variations of that same edit. If a provider call fails, do not retry ImageEdit automatically; explain the error and wait for the user to decide.'
return 'Use this tool only when the user wants to edit, combine, or visually reference existing images. referenced_image_paths is required and may contain only images the user supplied in this conversation: a path surfaced by [Image source: ...], a file the user attached with @, or a path returned by a prior ImageGen call. Never invent, search for, or substitute a path, and never open an image off disk yourself to feed it here — if the user means an image you have no path for, ask them to attach it. Preserve the full relevant user specification and repeat preservation constraints in every edit prompt. Provider and image model selection come from the current desktop session and are not tool arguments. One call represents one distinct prompt; use count only for variations of that same edit. If a provider call fails, do not retry ImageEdit automatically; explain the error and wait for the user to decide.'
},
get inputSchema(): EditInputSchema {
return editInputSchema()
+116 -3
View File
@@ -1,8 +1,13 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { getSessionId } from '../../bootstrap/state.js'
import {
clearUserProvidedImages,
registerUserProvidedImage,
} from '../../utils/userProvidedImages.js'
import { OPENAI_CODEX_OAUTH_FILE_ENV_KEY } from '../../services/openaiAuth/storage.js'
import type { ImageGenerationRuntimeConfig } from '../../services/imageGeneration/config.js'
import {
@@ -35,6 +40,9 @@ let outputDir: string | undefined
afterEach(async () => {
if (outputDir) await rm(outputDir, { recursive: true, force: true })
outputDir = undefined
// The registry is module-level session state; leaking it between tests would
// silently authorize paths a later test expects to be rejected.
clearUserProvidedImages()
})
describe('ImageGen backend', () => {
@@ -299,7 +307,7 @@ describe('ImageGen backend', () => {
}
})
test('rejects edit paths outside the session upload and generated-image roots', async () => {
test('rejects edit paths the user never provided, wherever they point', async () => {
outputDir = await mkdtemp(join(tmpdir(), 'imagegen-edit-root-'))
const outsideDir = await mkdtemp(join(tmpdir(), 'imagegen-edit-outside-'))
const outsidePath = join(outsideDir, 'private.png')
@@ -313,12 +321,117 @@ describe('ImageGen backend', () => {
outputDir,
inputRootDirs: [outputDir],
fetchImpl: async () => Response.json({ data: [] }),
})).rejects.toThrow('not a staged upload or a generated image from this session')
})).rejects.toThrow('was not provided by the user in this session')
} finally {
await rm(outsideDir, { recursive: true, force: true })
}
})
test('accepts an image the user attached with @, wherever it lives on disk', async () => {
// Regression: an @-mentioned file keeps its original path, so the session
// root dirs can never contain it. Before the registry existed this threw
// and the model told the user to re-attach a file they had just attached.
outputDir = await mkdtemp(join(tmpdir(), 'imagegen-at-mention-'))
const desktopDir = await mkdtemp(join(tmpdir(), 'imagegen-user-desktop-'))
const attachedPath = join(desktopDir, 'portrait.png')
await writeFile(attachedPath, PNG_BYTES)
try {
await registerUserProvidedImage(attachedPath)
const forms: FormData[] = []
const fetchImpl = async (
_input: string | URL | Request,
init?: RequestInit,
) => {
forms.push(init?.body as FormData)
return Response.json({ data: [{ b64_json: PNG_BYTES.toString('base64') }] })
}
const result = await generateImages({
prompt: 'Swap in this portrait; keep the rest of the poster unchanged',
count: 1,
referenced_image_paths: [attachedPath],
}, customConfig, {
fetchImpl,
outputDir,
// Deliberately excludes the attached file's directory: the registry is
// the only thing that can let it through.
inputRootDirs: [outputDir],
})
expect(result.operation).toBe('edit')
expect(result.inputImageCount).toBe(1)
expect(forms[0]?.getAll('image[]')).toHaveLength(1)
} finally {
await rm(desktopDir, { recursive: true, force: true })
}
})
test('recognizes an attached image through a symlink', async () => {
outputDir = await mkdtemp(join(tmpdir(), 'imagegen-symlink-'))
const realDir = await mkdtemp(join(tmpdir(), 'imagegen-symlink-real-'))
const linkDir = await mkdtemp(join(tmpdir(), 'imagegen-symlink-link-'))
const realPath = join(realDir, 'portrait.png')
const linkPath = join(linkDir, 'portrait.png')
await writeFile(realPath, PNG_BYTES)
await symlink(realPath, linkPath)
try {
// The user attached the link; the tool resolves to the real file. Both
// sides realpath(), so the two must still line up.
await registerUserProvidedImage(linkPath)
const result = await generateImages({
prompt: 'Edit the attached portrait',
count: 1,
referenced_image_paths: [linkPath],
}, customConfig, {
fetchImpl: async () =>
Response.json({ data: [{ b64_json: PNG_BYTES.toString('base64') }] }),
outputDir,
inputRootDirs: [outputDir],
})
expect(result.inputImageCount).toBe(1)
} finally {
await rm(realDir, { recursive: true, force: true })
await rm(linkDir, { recursive: true, force: true })
}
})
test('accepts an image pasted into the chat this session', async () => {
// Pasted and dropped images land in ~/.claude/image-cache/<sessionId>/,
// which is a distinct root from the bridge upload dir.
outputDir = await mkdtemp(join(tmpdir(), 'imagegen-pasted-'))
const configDir = await mkdtemp(join(tmpdir(), 'imagegen-config-'))
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = configDir
try {
const pastedDir = join(configDir, 'image-cache', getSessionId())
await mkdir(pastedDir, { recursive: true })
const pastedPath = join(pastedDir, '1.png')
await writeFile(pastedPath, PNG_BYTES)
const result = await generateImages({
prompt: 'Edit the pasted screenshot',
count: 1,
referenced_image_paths: [pastedPath],
}, customConfig, {
fetchImpl: async () =>
Response.json({ data: [{ b64_json: PNG_BYTES.toString('base64') }] }),
outputDir,
// No inputRootDirs override: this exercises defaultInputRootDirs().
})
expect(result.inputImageCount).toBe(1)
} finally {
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
}
await rm(configDir, { recursive: true, force: true })
}
})
test('rejects relay-provided download URLs instead of turning the desktop into an SSRF client', async () => {
outputDir = await mkdtemp(join(tmpdir(), 'imagegen-output-'))
const fetchImpl = async () => Response.json({
+19 -2
View File
@@ -21,7 +21,9 @@ import type {
} from '../../services/imageGeneration/config.js'
import { createCombinedAbortSignal } from '../../utils/combinedAbortSignal.js'
import { getCcHahaDir, getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { getImageStoreDir } from '../../utils/imageStore.js'
import { getProxyFetchOptions } from '../../utils/proxy.js'
import { isUserProvidedImage } from '../../utils/userProvidedImages.js'
export type ImageGenerationInput = {
prompt: string
@@ -616,12 +618,22 @@ async function prepareInputImages(
return Promise.all(requested.map(async (inputPath) => {
const resolvedPath = await realpath(inputPath).catch(() => null)
// Two ways an image earns the right to be uploaded to the image provider:
// it sits in a per-session directory we own (pasted, staged, or generated),
// or the user named it explicitly with @. Anything else — a path the model
// globbed, scraped out of a file, or guessed — is refused.
if (
!resolvedPath ||
!resolvedRoots.some((rootDir) => isPathInside(rootDir, resolvedPath))
!(
resolvedRoots.some((rootDir) => isPathInside(rootDir, resolvedPath)) ||
isUserProvidedImage(resolvedPath)
)
) {
throw new Error(
`Image edit input is not a staged upload or a generated image from this session: ${inputPath}`,
`Image edit input was not provided by the user in this session: ${inputPath}. ` +
'Usable inputs are images the user pasted or dropped into the chat, ' +
'attached with @, or images returned by an earlier ImageGen call. ' +
'Ask the user to attach the image instead of reading it from disk.',
)
}
@@ -659,7 +671,12 @@ async function prepareInputImages(
function defaultInputRootDirs(): string[] {
const sessionId = safeSessionId()
return [
// Bridge/desktop uploads.
join(getClaudeConfigHomeDir(), 'uploads', sessionId),
// Images pasted or dropped into the chat. getImageStoreDir() keys off the
// raw session id, which is what actually got written to disk — rebuilding
// the path from safeSessionId() here would miss it.
getImageStoreDir(),
join(getCcHahaDir(), 'generated-images', sessionId),
]
}
+11 -1
View File
@@ -69,6 +69,7 @@ import type {
Base64ImageSource,
} from '@anthropic-ai/sdk/resources/messages.mjs'
import { maybeResizeAndDownsampleImageBlock } from './imageResizer.js'
import { registerUserProvidedImage } from './userProvidedImages.js'
import type { PastedContent } from './config.js'
import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'
import { getSkillToolCommands, getMcpSkillCommands } from '../commands.js'
@@ -1555,6 +1556,8 @@ function getWorkflowSizeGuidelineAttachment(
* the tests on the real functions instead of a re-implementation.
*/
export const getAttachmentsForTesting = {
atMentionedFiles: (input: string, toolUseContext: ToolUseContext) =>
processAtMentionedFiles(input, toolUseContext),
workflowKeyword: (input: string | null, opts: { suppressed: boolean }) =>
getWorkflowKeywordAttachment(input, opts.suppressed),
ultracodeEffort: (messages: Message[] | undefined, ultracodeActive: boolean) =>
@@ -2064,7 +2067,7 @@ async function processAtMentionedFiles(
// If stat fails, continue with file logic
}
return await generateFileAttachment(
const attachment = await generateFileAttachment(
absoluteFilename,
toolUseContext,
'tengu_at_mention_extracting_filename_success',
@@ -2075,6 +2078,13 @@ async function processAtMentionedFiles(
limit: lineEnd && lineStart ? lineEnd - lineStart + 1 : undefined,
},
)
// Naming an image with @ is what authorizes ImageEdit to upload it to
// the image provider. A path the model found on its own stays
// off-limits — see utils/userProvidedImages.ts.
if (attachment?.type === 'file' && attachment.content.type === 'image') {
await registerUserProvidedImage(absoluteFilename)
}
return attachment
} catch {
logEvent('tengu_at_mention_extracting_filename_error', {})
}
@@ -0,0 +1,131 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { mkdtemp, realpath, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { getEmptyToolPermissionContext, type ToolUseContext } from '../Tool.js'
import { generateImages } from '../tools/ImageGenTool/backend.js'
import { getAttachmentsForTesting } from './attachments.js'
import {
clearUserProvidedImages,
isUserProvidedImage,
} from './userProvidedImages.js'
// A real 1x1 PNG: FileReadTool sniffs the header, so a stub buffer would take
// the text path and never mark the attachment as an image.
const ONE_PIXEL_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
)
function makeToolUseContext(): ToolUseContext {
return {
readFileState: new Map(),
abortController: new AbortController(),
getAppState: () => ({
toolPermissionContext: getEmptyToolPermissionContext(),
}),
} as unknown as ToolUseContext
}
let scratchDir: string | undefined
afterEach(async () => {
clearUserProvidedImages()
if (scratchDir) await rm(scratchDir, { recursive: true, force: true })
scratchDir = undefined
})
describe('@-mentioned images authorize ImageEdit', () => {
test('registers an image the user attached with @', async () => {
scratchDir = await mkdtemp(join(tmpdir(), 'at-mention-image-'))
const imagePath = join(scratchDir, 'portrait.png')
await writeFile(imagePath, ONE_PIXEL_PNG)
await getAttachmentsForTesting.atMentionedFiles(
`@${imagePath} use my headshot, leave everything else alone`,
makeToolUseContext(),
)
expect(isUserProvidedImage(await realpath(imagePath))).toBe(true)
})
test('registers a quoted @ path, the form used for non-ASCII filenames', async () => {
scratchDir = await mkdtemp(join(tmpdir(), 'at-mention-quoted-'))
const imagePath = join(scratchDir, '1正面.png')
await writeFile(imagePath, ONE_PIXEL_PNG)
await getAttachmentsForTesting.atMentionedFiles(
`@"${imagePath}" 使用我的头像,其他的不用动`,
makeToolUseContext(),
)
expect(isUserProvidedImage(await realpath(imagePath))).toBe(true)
})
test('does not register an @-mentioned text file', async () => {
scratchDir = await mkdtemp(join(tmpdir(), 'at-mention-text-'))
const textPath = join(scratchDir, 'notes.txt')
await writeFile(textPath, 'not an image')
await getAttachmentsForTesting.atMentionedFiles(
`@${textPath} summarize this`,
makeToolUseContext(),
)
expect(isUserProvidedImage(await realpath(textPath))).toBe(false)
})
test('an @-mentioned image survives all the way into an ImageEdit call', async () => {
// End-to-end over the exact sequence that used to fail: the user attaches a
// portrait with @, then asks for an edit. Neither half is mocked, so this
// also covers the seam between attachment handling and the tool backend.
scratchDir = await mkdtemp(join(tmpdir(), 'at-mention-e2e-'))
const outputDir = join(scratchDir, 'out')
const portraitPath = join(scratchDir, '1正面.png')
await writeFile(portraitPath, ONE_PIXEL_PNG)
await getAttachmentsForTesting.atMentionedFiles(
`@"${portraitPath}" 使用我的头像,其他的不用动`,
makeToolUseContext(),
)
const result = await generateImages({
prompt: 'Replace the poster subject with this portrait; keep all else',
count: 1,
referenced_image_paths: [portraitPath],
}, {
kind: 'openai_images',
providerId: 'relay-provider',
model: 'relay-image-model',
baseUrl: 'https://relay.example.test/v1',
apiKey: 'relay-secret',
}, {
fetchImpl: async () =>
Response.json({ data: [{ b64_json: ONE_PIXEL_PNG.toString('base64') }] }),
outputDir,
// Only the @ mention can authorize this path.
inputRootDirs: [outputDir],
})
expect(result.operation).toBe('edit')
expect(result.inputImageCount).toBe(1)
})
test('does not register an image that was never @-mentioned', async () => {
scratchDir = await mkdtemp(join(tmpdir(), 'at-mention-bystander-'))
const mentionedPath = join(scratchDir, 'mentioned.png')
const bystanderPath = join(scratchDir, 'private.png')
await writeFile(mentionedPath, ONE_PIXEL_PNG)
await writeFile(bystanderPath, ONE_PIXEL_PNG)
await getAttachmentsForTesting.atMentionedFiles(
`@${mentionedPath} edit this one`,
makeToolUseContext(),
)
expect(isUserProvidedImage(await realpath(mentionedPath))).toBe(true)
// Sitting in the same directory as an attached image grants nothing.
expect(isUserProvidedImage(await realpath(bystanderPath))).toBe(false)
})
})
+5 -1
View File
@@ -14,8 +14,12 @@ const storedImagePaths = new Map<number, string>()
/**
* Get the image store directory for the current session.
*
* Exported because ImageEdit trusts this directory as a source of edit inputs:
* everything in it was pasted or dropped into the chat by the user this
* session.
*/
function getImageStoreDir(): string {
export function getImageStoreDir(): string {
return join(getClaudeConfigHomeDir(), IMAGE_STORE_DIR, getSessionId())
}
+86
View File
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { mkdtemp, realpath, rm, symlink, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import {
clearUserProvidedImages,
isUserProvidedImage,
registerUserProvidedImage,
} from './userProvidedImages.js'
let scratchDir: string | undefined
afterEach(async () => {
clearUserProvidedImages()
if (scratchDir) await rm(scratchDir, { recursive: true, force: true })
scratchDir = undefined
})
describe('userProvidedImages', () => {
test('stores the real path so a symlinked attachment still matches', async () => {
scratchDir = await mkdtemp(join(tmpdir(), 'user-images-'))
const realPath = join(scratchDir, 'portrait.png')
const linkPath = join(scratchDir, 'link.png')
await writeFile(realPath, 'png')
await symlink(realPath, linkPath)
await registerUserProvidedImage(linkPath)
// Callers realpath() before asking, so the link must resolve to the same key.
expect(isUserProvidedImage(await realpath(realPath))).toBe(true)
})
test('does not authorize a path that was never registered', async () => {
scratchDir = await mkdtemp(join(tmpdir(), 'user-images-'))
const unregistered = join(scratchDir, 'private.png')
await writeFile(unregistered, 'png')
expect(isUserProvidedImage(await realpath(unregistered))).toBe(false)
})
test('ignores a path that does not exist', async () => {
scratchDir = await mkdtemp(join(tmpdir(), 'user-images-'))
const missing = join(scratchDir, 'missing.png')
await registerUserProvidedImage(missing)
expect(isUserProvidedImage(missing)).toBe(false)
})
test('evicts the oldest entries past the cap but keeps reused ones', async () => {
scratchDir = await mkdtemp(join(tmpdir(), 'user-images-'))
const paths: string[] = []
for (let index = 0; index < 200; index++) {
const filePath = join(scratchDir, `image-${index}.png`)
await writeFile(filePath, 'png')
paths.push(await realpath(filePath))
await registerUserProvidedImage(filePath)
}
// Re-registering the oldest entry moves it back to the newest position.
await registerUserProvidedImage(paths[0]!)
const overflowPath = join(scratchDir, 'overflow.png')
await writeFile(overflowPath, 'png')
await registerUserProvidedImage(overflowPath)
expect(isUserProvidedImage(paths[0]!)).toBe(true)
expect(isUserProvidedImage(await realpath(overflowPath))).toBe(true)
// paths[1] was the oldest once paths[0] was refreshed.
expect(isUserProvidedImage(paths[1]!)).toBe(false)
})
test('clearUserProvidedImages drops every authorization', async () => {
scratchDir = await mkdtemp(join(tmpdir(), 'user-images-'))
const filePath = join(scratchDir, 'portrait.png')
await writeFile(filePath, 'png')
await registerUserProvidedImage(filePath)
const resolved = await realpath(filePath)
expect(isUserProvidedImage(resolved)).toBe(true)
clearUserProvidedImages()
expect(isUserProvidedImage(resolved)).toBe(false)
})
})
+50
View File
@@ -0,0 +1,50 @@
/**
* Session-scoped registry of images the user explicitly handed to this
* conversation by naming them — currently `@`-mentioned image files.
*
* ImageEdit uploads raw bytes to a third-party image API, so it refuses
* arbitrary filesystem paths (see ImageGenTool/backend.ts). Staged uploads,
* pasted screenshots, and generated images all live under known per-session
* directories and are trusted by location. An `@`-mentioned file keeps its
* original path anywhere on disk, so it is recorded here instead: the
* authorization is the user naming the file, not the path.
*
* Paths the model discovered on its own — a Glob hit, a path scraped out of
* source code, a plain FileRead — are deliberately NOT registered.
*/
import { realpath } from 'fs/promises'
const MAX_TRACKED_IMAGES = 200
// Insertion-ordered, so the oldest entry is always the first key.
const authorizedImagePaths = new Set<string>()
/**
* Record an image the user explicitly named. Resolves symlinks so the stored
* key matches what ImageEdit checks after its own realpath() call.
*/
export async function registerUserProvidedImage(path: string): Promise<void> {
const resolvedPath = await realpath(path).catch(() => null)
if (!resolvedPath) return
// Re-adding would keep the original insertion position, which would make an
// actively reused image look stale to the eviction pass below.
authorizedImagePaths.delete(resolvedPath)
while (authorizedImagePaths.size >= MAX_TRACKED_IMAGES) {
const oldest = authorizedImagePaths.values().next().value
if (oldest === undefined) break
authorizedImagePaths.delete(oldest)
}
authorizedImagePaths.add(resolvedPath)
}
/**
* Whether the user named this image earlier in the session. Takes an
* already-resolved path — callers must realpath() before asking.
*/
export function isUserProvidedImage(resolvedPath: string): boolean {
return authorizedImagePaths.has(resolvedPath)
}
export function clearUserProvidedImages(): void {
authorizedImagePaths.clear()
}