Files
claudecodeui/server/shared/tests/image-attachments.test.ts
Haile 4cee5e7286 Fix/resolve different bugs (#964)
* fix: don't overlap thinking banner over conversation

* feat: support message queue and add attention indicator

* fix(shell): use c to copy for claude

* fix: strip timestamp tags from cursor

* fix: select project when loading session from direct URL

* feat: support attached images for all providers

* feat(opencode): support permission options

* fix: resolve source control and chat UX bugs

- make staging real in the git panel: new /stage and /unstage endpoints,
  /status now reports the actual index via porcelain -z (handles renames,
  conflicts, and paths with spaces), and Stage/Unstage All run real git
  commands instead of toggling client-side state
- add branch search to the header dropdown and Branches tab
- persist the chosen permission mode per provider so a brand-new chat
  keeps it once the session id arrives, instead of reverting to default
- replace cmdk's fuzzy model search with strict token matching so
  "chatgpt" no longer surfaces unrelated models
- unit tests for the git status parser

* feat(git): commit graph in history view and cross-spawn everywhere

- render a VSCode-style commit graph in the source control history:
  lane-assignment algorithm, SVG strip with colored rails, merge and
  branch curves, commit dots, and branch/tag badges per commit
- /commits returns parents and ref decorations across all branches
  (--branches --remotes --tags --topo-order) using unit-separator
  fields so pipes in commit subjects can't break parsing
- collect stats via a single --shortstat pass instead of one
  `git show --stat` call per commit; raise the history limit to 50
- replace child_process spawn with cross-spawn in all runtimes,
  routes, and services: it resolves .cmd shims and PATHEXT on
  Windows (fixing taskmaster's npx invocations) and delegates to
  native spawn elsewhere, removing the per-file win32 ternaries
- unit tests for the log parser and lane assignment

* fix: remove gemini support since google discontinued it

* fix: address code review findings

- validate chat image attachments server-side: only files inside the
  ~/.cloudcli/assets upload store may reach provider file reads
- harden asset serving with nosniff and attachment disposition for
  SVGs to prevent stored XSS
- show the timestamp on image-only user messages
- serialize git stage/unstage calls and defer the status re-sync so
  rapid toggles can't interleave or flicker
- use a literal hex fallback for commit ref badges (alpha suffix on a
  var() string produced invalid CSS)
- stop binding a URL session to a guening
  project instead
- add the missing attentionRequiredIndicator key to all sidebar locales
- clean up dangling conjunctions left the
  de/ru/tr/ja/zh-CN/zh-TW READMEs

* fix: address code scanning findings

- enforce a second image trust boundary in the provider builders:
  buildClaudeUserContent/buildCodexInputItems only accept files inside
  the upload store or the run's working directory (CodeQL path
  injection)
- drop an always-true selectedProject check in handleSubmit
- remove the unused resume option from spawnCursor

* fix: use CodeQL-recognized containment check for image reads

Replace the path.relative guard in isPathInsideDirectory with the
resolve + startsWith(root + sep) idiom so the path-injection barrier
is visible to code scanning; behavior is unchanged.

* fix(security): canonicalize Claude image attachment reads

Resolve image attachment paths with realpath before reading so symlinks
inside allowed roots cannot escape to arbitrary files. Preserve support for
symlinked project/upload roots and add focused coverage for both cases.

* fix: show agent subtask

* fix(sessions): title app-created sessions from the first user message

App-created sessions (started by sending a message from cloudcli) were
titled with placeholder names — "Untitled Codex Session" from the disk
indexer and, briefly, "New session" from the empty canonical upsert —
before a later sync finally settled on the right text, causing the
sidebar title to flicker.

- Codex/OpenCode synchronizers now title app-created sessions (distinct
  app id mapped to a provider id) from the first user message, while
  sessions found purely by indexing keep their existing setup. Claude
  keeps its AI-generated titles; Cursor already used the first message.
- Decode OpenCode's JSON-string-literal prompts so titles no longer
  surface wrapped in quotes; hoist unwrapJsonStringLiteral into
  shared/utils since it's now used by both the reader and synchronizer.
- Guard the sidebar upsert merge so an empty summary can never blank out
  a title that is already set.
- Add codex/opencode synchronizer tests for the app-created vs indexed
  naming paths.

* fix(chat): make message queuing reliable across sessions and turn boundaries

Queued messages had four related defects:

- A queued message flashed and then vanished at flush time: concurrent
  transcript refreshes (the `complete` handler racing the watcher-triggered
  update) could resolve out of order, letting a stale response overwrite
  newer server messages after the optimistic row was pruned. Session slots
  now carry a monotonic fetch ticket and discard stale fetch/refresh/
  fetchMore responses.

- Switching sessions flushed the previous session's queued draft into the
  newly viewed one (sending it with the wrong provider's settings, e.g. a
  Claude model into a Codex session). The composer flush is now scoped to
  its session, and a draft restored into an idle session sends after a
  short grace period so a live-run ack can cancel it.

- The thinking banner never appeared for a queued turn: the chat handler's
  session-keyed completeRun safety net could fire after a queued message
  had already started the session's next run, emitting a spurious
  `complete` that killed it. The safety net is now scoped to its own run
  via completeRunIfCurrent (with a regression test).

- Queued messages only sent while their session was being viewed. Drafts
  now persist their send options (model, effort, permissions) at queue
  time, and a new app-level useQueuedMessageAutoSend hook dispatches a
  non-viewed session's queued message as soon as its run completes, using
  the storage key as the claim ticket to prevent double sends.
2026-07-08 10:32:32 +02:00

299 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
appendImagesInputTag,
buildClaudeUserContent,
buildCodexInputItems,
isAllowedImageSourcePath,
normalizeImageDescriptors,
parseImagesInputTag,
resolveImageMediaType,
toImageAttachments,
} from '@/shared/image-attachments.js';
// 1x1 transparent PNG
const PNG_BYTES = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
);
const SYMLINK_UNSUPPORTED_CODES = new Set(['EACCES', 'EINVAL', 'ENOSYS', 'ENOTSUP', 'EPERM']);
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error;
}
async function createSymlinkIfSupported(
target: string,
linkPath: string,
type: 'dir' | 'file' | 'junction',
): Promise<boolean> {
try {
await symlink(target, linkPath, type);
return true;
} catch (error) {
if (
isErrnoException(error) &&
typeof error.code === 'string' &&
SYMLINK_UNSUPPORTED_CODES.has(error.code)
) {
return false;
}
throw error;
}
}
test('normalizeImageDescriptors accepts objects and bare paths, drops junk', () => {
const descriptors = normalizeImageDescriptors([
{ path: '.cloudcli/assets/a.png', name: 'a.png', mimeType: 'image/png' },
'scripts/pic.jpg',
{ name: 'no-path.png' },
42,
null,
'',
]);
assert.deepEqual(descriptors, [
{ path: '.cloudcli/assets/a.png', name: 'a.png', mimeType: 'image/png' },
{ path: 'scripts/pic.jpg' },
]);
assert.deepEqual(normalizeImageDescriptors(undefined), []);
assert.deepEqual(normalizeImageDescriptors('not-an-array'), []);
});
test('appendImagesInputTag and parseImagesInputTag round-trip', () => {
const prompt = 'Describe these screenshots.\n\nFocus on the header.';
const tagged = appendImagesInputTag(prompt, [
{ path: '.cloudcli/assets/1-a.png' },
{ path: '.cloudcli\\assets\\2-b.jpg' },
]);
assert.ok(tagged.startsWith(prompt));
assert.ok(tagged.includes('<images_input>'));
assert.ok(tagged.includes('</images_input>'));
assert.ok(tagged.includes('The user attached 2 image(s)'));
const parsed = parseImagesInputTag(tagged);
assert.equal(parsed.text, prompt);
// Backslashes are normalized so references stay portable.
assert.deepEqual(parsed.imagePaths, ['.cloudcli/assets/1-a.png', '.cloudcli/assets/2-b.jpg']);
});
test('original filenames round-trip through the tag', () => {
const tagged = appendImagesInputTag('compare these', [
{ path: 'C:/Users/x/.cloudcli/assets/1-a.png', name: 'screenshot (final).png' },
{ path: 'C:/Users/x/.cloudcli/assets/2-b.jpg' },
]);
const parsed = parseImagesInputTag(tagged);
assert.equal(parsed.text, 'compare these');
// Parentheses are dropped from names so the "(original name: ...)" suffix
// stays parseable; the path-only entry carries no name.
assert.deepEqual(parsed.attachments, [
{ path: 'C:/Users/x/.cloudcli/assets/1-a.png', name: 'screenshot final.png' },
{ path: 'C:/Users/x/.cloudcli/assets/2-b.jpg' },
]);
});
test('only the LAST images_input block is treated as the attachment carrier', () => {
const userTypedTag = 'What does <images_input> mean in this codebase?';
const tagged = appendImagesInputTag(
`${userTypedTag}\n\n<images_input>\nfake user block\n</images_input>\n\nAlso check this.`,
[{ path: 'C:/Users/x/.cloudcli/assets/real.png' }],
);
const parsed = parseImagesInputTag(tagged);
assert.ok(parsed.text.includes('fake user block'));
assert.ok(parsed.text.includes('Also check this.'));
assert.deepEqual(parsed.imagePaths, ['C:/Users/x/.cloudcli/assets/real.png']);
});
test('appendImagesInputTag without images returns the prompt untouched', () => {
assert.equal(appendImagesInputTag('hello', []), 'hello');
assert.equal(appendImagesInputTag('hello', undefined), 'hello');
});
test('parseImagesInputTag handles prompts flattened to one line for cmd.exe shims', () => {
// Windows spawn runtimes collapse newlines before passing the argument to
// .cmd-shimmed CLIs; the persisted prompt is then a single line.
const flattened = appendImagesInputTag('now?', [{ path: 'C:/Users/x/.cloudcli/assets/a.jpg' }])
.replace(/\s*\r?\n\s*/g, ' ')
.trim();
assert.ok(!flattened.includes('\n'));
const parsed = parseImagesInputTag(flattened);
assert.equal(parsed.text, 'now?');
assert.deepEqual(parsed.imagePaths, ['C:/Users/x/.cloudcli/assets/a.jpg']);
});
test('parseImagesInputTag leaves text without a tag untouched', () => {
const text = 'Just a normal prompt with [brackets] and JSON ["like"] content.';
const parsed = parseImagesInputTag(text);
assert.equal(parsed.text, text);
assert.deepEqual(parsed.imagePaths, []);
});
test('parseImagesInputTag strips a malformed tag body without attaching images', () => {
const text = 'prompt\n\n<images_input>\nnot json here\n</images_input>';
const parsed = parseImagesInputTag(text);
assert.equal(parsed.text, 'prompt');
assert.deepEqual(parsed.imagePaths, []);
});
test('toImageAttachments maps paths to posix attachment records', () => {
assert.deepEqual(toImageAttachments(['a\\b\\c.png', 'd/e.jpg']), [
{ path: 'a/b/c.png' },
{ path: 'd/e.jpg' },
]);
});
test('resolveImageMediaType prefers the mime type and falls back to the extension', () => {
assert.equal(resolveImageMediaType({ path: 'x.bin', mimeType: 'image/webp' }), 'image/webp');
assert.equal(resolveImageMediaType({ path: 'x.JPG' }), 'image/jpeg');
assert.equal(resolveImageMediaType({ path: 'x.png' }), 'image/png');
assert.equal(resolveImageMediaType({ path: 'x.unknown' }), null);
});
test('buildClaudeUserContent reads image bytes into base64 blocks', async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-'));
try {
await writeFile(path.join(tempDir, 'shot.png'), PNG_BYTES);
const content = await buildClaudeUserContent(
'What is in this image?',
[{ path: 'shot.png', mimeType: 'image/png' }],
tempDir,
);
assert.equal(content.length, 2);
assert.deepEqual(content[0], { type: 'text', text: 'What is in this image?' });
assert.equal(content[1].type, 'image');
const imageBlock = content[1] as Extract<(typeof content)[number], { type: 'image' }>;
assert.equal(imageBlock.source.type, 'base64');
assert.equal(imageBlock.source.media_type, 'image/png');
assert.equal(imageBlock.source.data, PNG_BYTES.toString('base64'));
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
test('buildClaudeUserContent skips unsupported types and unreadable files', async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-'));
try {
await writeFile(path.join(tempDir, 'vector.svg'), '<svg></svg>');
const content = await buildClaudeUserContent(
'prompt',
[
{ path: 'vector.svg', mimeType: 'image/svg+xml' },
{ path: 'missing.png', mimeType: 'image/png' },
],
tempDir,
);
// Only the text block survives; the prompt still goes through.
assert.deepEqual(content, [{ type: 'text', text: 'prompt' }]);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
test('buildClaudeUserContent refuses symlinked images outside allowed roots', async (t) => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-'));
const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-outside-'));
try {
const outsideFile = path.join(outsideDir, 'secret.png');
await writeFile(outsideFile, PNG_BYTES);
const linkPath = path.join(tempDir, 'linked-secret.png');
if (!(await createSymlinkIfSupported(outsideFile, linkPath, 'file'))) {
t.skip('Symlink creation is not supported in this environment');
return;
}
const content = await buildClaudeUserContent(
'prompt',
[{ path: 'linked-secret.png', mimeType: 'image/png' }],
tempDir,
);
assert.deepEqual(content, [{ type: 'text', text: 'prompt' }]);
} finally {
await rm(tempDir, { recursive: true, force: true });
await rm(outsideDir, { recursive: true, force: true });
}
});
test('buildClaudeUserContent accepts images under a symlinked cwd', async (t) => {
const realProjectDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-project-'));
const linkParentDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-link-'));
try {
await writeFile(path.join(realProjectDir, 'shot.png'), PNG_BYTES);
const linkCwd = path.join(linkParentDir, 'project-link');
const linkType = process.platform === 'win32' ? 'junction' : 'dir';
if (!(await createSymlinkIfSupported(realProjectDir, linkCwd, linkType))) {
t.skip('Symlink creation is not supported in this environment');
return;
}
const content = await buildClaudeUserContent(
'prompt',
[{ path: 'shot.png', mimeType: 'image/png' }],
linkCwd,
);
assert.equal(content.length, 2);
assert.equal(content[1].type, 'image');
const imageBlock = content[1] as Extract<(typeof content)[number], { type: 'image' }>;
assert.equal(imageBlock.source.data, PNG_BYTES.toString('base64'));
} finally {
await rm(linkParentDir, { recursive: true, force: true });
await rm(realProjectDir, { recursive: true, force: true });
}
});
test('buildCodexInputItems emits text plus absolute local_image paths', () => {
const cwd = path.join(os.tmpdir(), 'codex-project');
const items = buildCodexInputItems('Describe this image:', [{ path: '.cloudcli/assets/pic.jpg' }], cwd);
assert.equal(items.length, 2);
assert.deepEqual(items[0], { type: 'text', text: 'Describe this image:' });
assert.equal(items[1].type, 'local_image');
const imageItem = items[1] as Extract<(typeof items)[number], { type: 'local_image' }>;
assert.ok(path.isAbsolute(imageItem.path));
assert.equal(imageItem.path, path.resolve(cwd, '.cloudcli/assets/pic.jpg'));
});
test('isAllowedImageSourcePath only accepts the upload store and the run cwd', () => {
const cwd = path.join(os.tmpdir(), 'some-project');
const uploadStore = path.join(os.homedir(), '.cloudcli', 'assets');
assert.equal(isAllowedImageSourcePath(path.join(uploadStore, 'shot.png'), cwd), true);
assert.equal(isAllowedImageSourcePath(path.join(cwd, 'docs', 'diagram.png'), cwd), true);
assert.equal(isAllowedImageSourcePath(path.join(os.homedir(), '.ssh', 'id_rsa'), cwd), false);
assert.equal(isAllowedImageSourcePath(path.join(cwd, '..', 'other-project', 'x.png'), cwd), false);
// The roots themselves are directories, not readable image files.
assert.equal(isAllowedImageSourcePath(cwd, cwd), false);
});
test('provider builders refuse descriptors outside the allowed roots', async () => {
const cwd = path.join(os.tmpdir(), 'codex-project');
const outsidePath = path.join(os.homedir(), '.ssh', 'id_rsa.png');
const codexItems = buildCodexInputItems('prompt', [{ path: outsidePath }], cwd);
assert.deepEqual(codexItems, [{ type: 'text', text: 'prompt' }]);
const claudeContent = await buildClaudeUserContent(
'prompt',
[{ path: outsidePath, mimeType: 'image/png' }],
cwd,
);
assert.deepEqual(claudeContent, [{ type: 'text', text: 'prompt' }]);
});