Skip to content

Host-readable absolute --image paths render as broken thumbnails in project threads #2449

Description

@brsbl

Summary

bb thread spawn (and related thread commands) document --image as accepting a “host-readable absolute or uploaded attachment image path.” An absolute image path is accepted and reaches the agent, but the thread task/message renders a broken thumbnail when the thread belongs to a project.

The prompt-delivery contract and timeline-display contract disagree.

Reproduction

  1. Put a valid PNG at an absolute path readable by the target thread host, for example /tmp/reference.png.

  2. Spawn a project thread with that image:

    bb thread spawn <project/thread options> --image /tmp/reference.png "Inspect this reference"
  3. Open the spawned thread.

Expected

  • The agent receives the image.
  • The task/message card displays the image thumbnail and lightbox normally.

Actual

  • The agent receives the image.
  • The task/message card shows a broken-image icon and the filename instead of the image.

Uploading the image through bb project attachment upload first and passing its returned project-relative path renders correctly.

Confirmed mechanism

The persisted prompt input contains an absolute local-image path:

{ "type": "localImage", "path": "/tmp/reference.png" }

The timeline resolver checks projectId before checking whether the path is absolute, so every non-URL image in a project thread is rewritten as a project-attachment content URL:

export function toUserAttachmentImageSrc(
pathOrUrl: string,
projectId?: string,
): string {
if (/^(https?:|data:|blob:)/i.test(pathOrUrl)) {
return pathOrUrl;
}
if (projectId) {
return buildProjectAttachmentContentUrl(projectId, pathOrUrl);
}
if (/^file:/i.test(pathOrUrl)) {
return pathOrUrl;
}
const normalized = pathOrUrl.replaceAll("\\", "/");
if (/^[a-zA-Z]:\//.test(normalized)) {
return `file:///${encodeURI(normalized)}`;
}
if (normalized.startsWith("/")) {
return `file://${encodeURI(normalized)}`;

That endpoint only serves paths contained within <dataDir>/attachments/<projectId>; an absolute host path is rejected as escaping the project attachment directory:

function projectAttachmentDir(dataDir: string, projectId: string): string {
return join(dataDir, "attachments", projectId);
}
function resolveAttachmentPath(
attachmentDir: string,
relativePath: string,
): string {
const normalizedRelativePath = normalize(relativePath.replaceAll("\\", "/"));
const resolvedAttachmentDir = resolve(attachmentDir);
const resolvedCandidatePath = resolve(
resolvedAttachmentDir,
normalizedRelativePath,
);
if (resolvedCandidatePath === resolvedAttachmentDir) {
throw new ApiError(
400,
"invalid_request",
"Attachment path must refer to a file inside the project directory",
);
}
const resolvedPath = resolveContainedPath({
rootPath: resolvedAttachmentDir,
candidatePath: resolvedCandidatePath,
});
if (resolvedPath) {
return resolvedPath;
}
throw new ApiError(
400,
"invalid_request",
"Attachment path escapes project directory",
);

Meanwhile, the CLI explicitly advertises absolute host-readable paths:

.option(
"--file <path>",
"Pass a host-readable absolute or uploaded attachment file path (repeatable)",
collectOption,
[],
)
.option(
"--image <path>",
"Pass a host-readable absolute or uploaded attachment image path (repeatable)",
collectOption,
[],

Suggested contract

Normalize host-readable image paths into durable project attachments before persisting/displaying the prompt input, or provide a correctly authorized host-file rendering path. The first option would keep timeline rendering durable after the original host file moves or disappears.

Acceptance criteria

  • A valid absolute --image path accepted by thread spawn/tell/fork renders in the resulting project-thread timeline.
  • The agent and the UI resolve the same image bytes.
  • Existing uploaded project-relative attachments, HTTP/data/blob images, and non-project threads continue to work.
  • Remote-host paths are covered rather than assuming the bb server and execution host share a filesystem.
  • Regression coverage exercises the CLI/server prompt boundary and the timeline image-source resolution.

Verified against origin/main at 2cfd2b5df57daeed51ea544b54a8869bbec1c478.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingclibb CLIconfirmed-reproBug reproduced independently; see linked reportthreadsTurns, timeline, messaging, forks

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions