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
-
Put a valid PNG at an absolute path readable by the target thread host, for example /tmp/reference.png.
-
Spawn a project thread with that image:
bb thread spawn <project/thread options> --image /tmp/reference.png "Inspect this reference"
-
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.
Summary
bb thread spawn(and related thread commands) document--imageas 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
Put a valid PNG at an absolute path readable by the target thread host, for example
/tmp/reference.png.Spawn a project thread with that image:
Open the spawned thread.
Expected
Actual
Uploading the image through
bb project attachment uploadfirst 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
projectIdbefore checking whether the path is absolute, so every non-URL image in a project thread is rewritten as a project-attachment content URL:bb/apps/app/src/lib/user-attachment-images.ts
Lines 3 to 22 in 2cfd2b5
That endpoint only serves paths contained within
<dataDir>/attachments/<projectId>; an absolute host path is rejected as escaping the project attachment directory:bb/apps/server/src/services/projects/attachments.ts
Lines 63 to 99 in 2cfd2b5
Meanwhile, the CLI explicitly advertises absolute host-readable paths:
bb/apps/cli/src/commands/thread/spawn.ts
Lines 208 to 218 in 2cfd2b5
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
--imagepath accepted by thread spawn/tell/fork renders in the resulting project-thread timeline.Verified against
origin/mainat2cfd2b5df57daeed51ea544b54a8869bbec1c478.