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 workingthreadsTurns, timeline, messaging, forks

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions