← reports

#1182 · Chat links starting with ~/ resolve against the project root

Bug Medium Effort: Small ui open on GitHub 2026-08-18 base 16ceb3a540f81c1189efaffb27a39b1d9443abf5 (main)

Verdict: REPRODUCED · root-cause confidence: high · linked open PRs: none (#1504 was an agent-opened duplicate PR, closed; issue is assigned to @arunsathiya)

TL;DR

Plain-language framing. When an agent's reply in a bb thread contains a file path, the app turns it into a clickable link that opens the file in the side panel. Paths are either absolute (/home/me/x.md) or relative to the workspace (src/x.md<workspace>/src/x.md). Shell-style home paths (~/.config/example.md) are neither, but the renderer's relative-link resolver has no rule for them.

What the user sees: an assistant message with `~/.config/example.md` (inline code ending in .md) or [text](~/.config/example.md) renders as a local-file link with the external-link icon. Clicking it opens a workspace file tab titled ~/.config/example.md that says Failed to load file; pressing Open in editor shows the toast "Failed to open file locally — Open target path does not exist: <workspace>/~/.config/example.md".

What is actually wrong: resolveRelativeLocalFileHref in apps/app/src/components/ui/markdown-local-file-link.ts rejects absolute paths, fragments, queries and URI schemes, and then treats everything else as workspace-relative and string-joins it onto the workspace root. ~/.config/example.md therefore becomes /tmp/bb-1182-scratch/~/.config/example.md. Because that string is under the workspace root, every later containment check passes and the app happily opens a workspace file tab for a directory literally named ~. The report's description of the cause is accurate. Not fixed on origin/main as of today.

Claims vs findings

ClaimStatusEvidence
A chat message containing ~/.config/example.md renders as a clickable linkVerified (with a nuance)Only for assistant messages, and only when the path is (a) inline code ending in .md/.markdown, or (b) an explicit markdown link [t](~/…). Bare unformatted text is not autolinked; user messages are not linkified. Screenshot 1182-inline-code-open-in-editor.png shows both `~/.config/example.md` and `~/notes.md` underlined with the link icon; DOM hrefs were file:///tmp/bb-1182-scratch/~/.config/example.md and file:///tmp/bb-1182-scratch/~/notes.md.
Clicking opens a file tab in the secondary panelVerifiedTab example.md / notes.md opens with path label ~/.config/example.md and body Failed to load file (1182-after-click.png).
Open in editor fails with <project root>/~/.config/example.mdVerifiedToast: Failed to open file locally · Open target path does not exist: /tmp/bb-1182-scratch/~/.config/example.md (1182-open-in-editor.png). Message originates in packages/local-open-targets/src/index.ts:978 (requireOpenablePath) on the host daemon.
resolveRelativeLocalFileHref treats every non-absolute href as workspace-relative and does not handle ~/VerifiedRejection list at L294–L305 has no tilde case; join at L320–L323. Unit test at the exact function fails on base with expected '/Users/me/bb/~/.config/example.md' to be null.
Result passes isAbsoluteFilePathWithinRoot, so resolveThreadLocalFileLink classifies it as a workspace fileVerifiedTest prints -> workspace link: { path: '/Users/me/bb/~/.config/example.md' }; in the app the tab is a workspace tab (relative label ~/.config/example.md).
No tilde expansion exists in the renderer link codeVerifiedgrep -n '~' apps/app/src/components/ui/markdown-local-file-link.ts finds nothing; the renderer receives no home-directory value.
(Comment) Rejecting decoded ~/ "also needs to reject the percent-encoded forms, otherwise %7E/ still reaches the join"Refuted as statedresolveRelativeLocalFileHref calls safeDecodeURIComponent(href) before the checks (L292–L293), so a check on the decoded parsedHref.path already covers %7E/. My test includes %7E/.config/example.md: it resolves to the same buggy /Users/me/bb/~/.config/example.md today, and is rejected by the one-line decoded check in the proposed fix. Only a double-encoded %257E/ survives one decode, and that yields the harmless literal <root>/%7E/…, not a tilde path.

Environment

Minimal reproduction

A. Unit test at the exact code path (fails on base)

File: 1182/repro/markdown-local-file-link.issue-1182.test.ts (copy to apps/app/src/components/ui/). Output: 1182/repro/vitest-output.txt.

cd apps/app
cp /tmp/bb-reports/issues/1182/repro/markdown-local-file-link.issue-1182.test.ts src/components/ui/
pnpm exec vitest run src/components/ui/markdown-local-file-link.issue-1182.test.ts
import { describe, expect, it } from "vitest";

import {
  parseLocalFileHref,
  resolveRelativeLocalFileHref,
} from "./markdown-local-file-link";

// Repro for get-bb/bb#1182: a chat link whose href starts with `~/` is treated
// as workspace-relative and joined onto the workspace root.
const workspaceRootPath = "/Users/me/bb";

describe("issue #1182: home-relative hrefs", () => {
  it.each([
    ["~"],
    ["~/.config/example.md"],
    ["%7E/.config/example.md"],
    ["~/.config/example.md:12"],
    ["~/notes.md#L3-L5"],
    ["~alice/notes.md"],
  ])("leaves %s as plain text (not a workspace file)", (href) => {
    const resolved = resolveRelativeLocalFileHref({
      baseDir: workspaceRootPath,
      href,
      rootPath: workspaceRootPath,
    });
    // BUG on 16ceb3a54: this returns "/Users/me/bb/~/.config/example.md"
    expect(resolved).toBeNull();
  });

  // Guard against over-rejecting: a file whose *name* merely starts with `~`
  // is a legitimate relative path and must stay a workspace file. These pass
  // on 16ceb3a54 and must keep passing after the fix.
  it.each([
    ["~notes.md", "/Users/me/bb/~notes.md"],
    ["~$report.docx", "/Users/me/bb/~$report.docx"],
    ["~notes.md:3", "/Users/me/bb/~notes.md:3"],
    ["docs/~draft.md", "/Users/me/bb/docs/~draft.md"],
  ])("still resolves %s as a workspace file", (href, expected) => {
    expect(
      resolveRelativeLocalFileHref({
        baseDir: workspaceRootPath,
        href,
        rootPath: workspaceRootPath,
      }),
    ).toBe(expected);
  });

  it("documents the actual (buggy) behaviour on 16ceb3a54", () => {
    const resolved = resolveRelativeLocalFileHref({
      baseDir: workspaceRootPath,
      href: "~/.config/example.md",
      rootPath: workspaceRootPath,
    });
    // This is what the app currently produces; it then passes the
    // "contained in root" check and opens a workspace file tab.
    const link = parseLocalFileHref({
      absoluteLinks: { kind: "contained", rootPath: workspaceRootPath },
      href: resolved ?? "",
    });
    // eslint-disable-next-line no-console
    console.log("resolved href:", resolved, "-> workspace link:", link);
    expect(link?.path).not.toBe("/Users/me/bb/~/.config/example.md");
  });
});

Expected: all 11 pass (tilde hrefs resolve to null, i.e. left as ordinary links; names that merely start with ~ still resolve). Actual on 16ceb3a54: 7 fail / 4 pass — the four "still resolves" guards pass on base (they exist to keep the fix from over-rejecting), every home-path case fails:

 ❯ src/components/ui/markdown-local-file-link.issue-1182.test.ts (11 tests | 7 failed)
     × leaves ~ as plain text (not a workspace file)
       → expected '/Users/me/bb/~' to be null
     × leaves ~/.config/example.md as plain text (not a workspace file)
       → expected '/Users/me/bb/~/.config/example.md' to be null
     × leaves %7E/.config/example.md as plain text (not a workspace file)
       → expected '/Users/me/bb/~/.config/example.md' to be null
     × leaves ~/.config/example.md:12 as plain text (not a workspace file)
       → expected '/Users/me/bb/~/.config/example.md:12' to be null
     × leaves ~/notes.md#L3-L5 as plain text (not a workspace file)
       → expected '/Users/me/bb/~/notes.md#L3-L5' to be null
     × leaves ~alice/notes.md as plain text (not a workspace file)
       → expected '/Users/me/bb/~alice/notes.md' to be null
     ✓ still resolves ~notes.md as a workspace file
     ✓ still resolves ~$report.docx as a workspace file
     ✓ still resolves ~notes.md:3 as a workspace file
     ✓ still resolves docs/~draft.md as a workspace file
     × documents the actual (buggy) behaviour on 16ceb3a54
       → expected '/Users/me/bb/~/.config/example.md' not to be '/Users/me/bb/~/.config/example.md'
stdout | documents the actual (buggy) behaviour on 16ceb3a54
resolved href: /Users/me/bb/~/.config/example.md -> workspace link: { lineRange: null, path: '/Users/me/bb/~/.config/example.md' }

 Test Files  1 failed (1)
      Tests  7 failed | 4 passed (11)

B. Live repro in the app

  1. Start a dev instance and create a scratch project:
    # run everything from the bb repo root of your worktree
    scripts/bb-dev-app current            # prints App/Server/Host daemon URLs for YOUR worktree (ports differ per worktree)
    eval "$(scripts/bb-dev-app env)"      # sets BB_SERVER_URL etc. to those values
    ( mkdir -p /tmp/bb-1182-scratch && cd /tmp/bb-1182-scratch && git init -q && echo "# scratch" > README.md && git add . && git -c user.email=a@b -c user.name=qa commit -qm init )
    pnpm bb:dev machine list              # note the host id (mine: host_7jfebaa4wr; the verifier's: host_ai8j9cyts3)
    curl -s -X POST $BB_SERVER_URL/api/v1/projects -H 'content-type: application/json' \
      -d '{"name":"qa","source":{"type":"local_path","path":"/tmp/bb-1182-scratch","hostId":"<host id from machine list>"}}'
    # -> {"id":"proj_…", ...}   (substitute this project id, and the thread id spawned below, in the following commands;
    #    the ids/ports shown further down — proj_xfhduummc3, thr_em7hmw2zzh, :17374/:25374 — are from the author's instance)
  2. Get an assistant message that contains tilde paths (a real turn; note: run the CLI via node packages/scripts/dist/commands/run-cli.js, not pnpm bb:dev, so the shell does not eat the backticks; unset BB_THREAD_ID if you are inside a bb thread):
    unset BB_THREAD_ID BB_ENVIRONMENT_ID BB_THREAD_STORAGE
    node packages/scripts/dist/commands/run-cli.js thread spawn --project proj_xfhduummc3 --provider claude-code --permission-mode full \
      --prompt 'Reply with exactly the following line and nothing else: Config lives at [this link](~/.config/example.md).'
    node packages/scripts/dist/commands/run-cli.js thread tell thr_em7hmw2zzh \
      'Reply with exactly this one line, wrapping the path in backticks as inline code, nothing else: The config file is `~/.config/example.md` and notes are in `~/notes.md`.'
    node packages/scripts/dist/commands/run-cli.js thread wait thr_em7hmw2zzh
    node packages/scripts/dist/commands/run-cli.js thread output thr_em7hmw2zzh
    # The config file is `~/.config/example.md` and notes are in `~/notes.md`.
  3. Open http://localhost:17374/projects/proj_xfhduummc3/threads/thr_em7hmw2zzh. Expected: tilde paths are plain text/plain code. Actual: they are underlined local-file links with the external-link icon (below). DOM check (browser-step4.js):
    [{"text":"this link","href":"file:///tmp/bb-1182-scratch/~/.config/example.md"},
     {"text":"~/.config/example.md","href":"file:///tmp/bb-1182-scratch/~/.config/example.md"},
     {"text":"~/notes.md","href":"file:///tmp/bb-1182-scratch/~/notes.md"}]
  4. Click the link. Actual: a workspace file tab opens with path label ~/.config/example.md and body Failed to load file.
  5. Click Open in editor (external-link button in the tab header, Ctrl+O). Actual: toast Failed to open file locally — Open target path does not exist: /tmp/bb-1182-scratch/~/.config/example.md.
thread before click
Step 3 (first message). The assistant text [this link](~/.config/example.md) renders as a local-file link — note the small external-link icon after "this link", which the app only adds when it classified the href as a local file.
file tab opened
Step 4. Clicking opens a workspace file tab; the header path reads ~/.config/example.md (a workspace-relative path whose first segment is a literal ~) and the body says "Failed to load file".
open in editor toast
Step 5. After pressing Open in editor: toast bottom-right, "Open target path does not exist: /tmp/bb-1182-scratch/~/.config/example.md" — the literal ~ joined onto the workspace root.
inline code variant
Inline-code variant (second message). Both `~/.config/example.md` and `~/notes.md` in the assistant reply are links (the same paths in the user's message above are correctly not linkified). Clicking ~/notes.md and Open in editor gives "…does not exist: /tmp/bb-1182-scratch/~/notes.md".
hover on Open in editor button
Between steps 4 and 5: hovering the external-link button in the file tab header shows the "Open in editor" tooltip; this is the button that produces the toast in the next screenshot.
inline code links before click
Inline-code variant before clicking: both `~/.config/example.md` and `~/notes.md` in the assistant reply are rendered as underlined links with the local-file icon.
with proposed fix
Same thread after applying the proposed one-function fix (vite HMR): inline code is plain code again, and the explicit markdown link stays an ordinary anchor without the local-file icon (href stays ~/.config/example.md). Note: the example.md / notes.md tabs still open in the right-hand panel are stale from steps 4–5 (they were opened before the fix and were not closed before capturing); with the fix applied nothing in the message is clickable as a workspace file, so no new tab can be opened.

Root cause

The relative-link resolver has an allow-by-default shape: it enumerates things that are not workspace-relative and joins everything else. apps/app/src/components/ui/markdown-local-file-link.ts#L283-L336:

const decodedHref = safeDecodeURIComponent(href);          // L292 — `%7E/` is already `~/` here
const parsedHref = parseLineSuffix(decodedHref);
if (
  href.trim() !== href ||
  decodedHref.trim() !== decodedHref ||
  parsedHref === null ||
  parsedHref.path.length === 0 ||
  parsedHref.path.startsWith("/") ||                        // absolute
  parsedHref.path.startsWith("#") ||                        // fragment
  parsedHref.path.startsWith("?") ||                        // query
  URI_SCHEME_PATTERN.test(parsedHref.path)                  // scheme
) {
  return null;                                              // no `~` case
}
…
const joinedPath =
  normalizedBaseDir === "/"
    ? `/${parsedHref.path}`
    : `${normalizedBaseDir}/${parsedHref.path}`;            // L320-323: "/tmp/bb-1182-scratch/~/.config/example.md"
const normalizedHrefPath = normalizeAbsoluteFilePath({ path: joinedPath });
if (normalizedHrefPath === null ||
    !isAbsoluteFilePathWithinRoot({ candidatePath: normalizedHrefPath, rootPath: normalizedRootPath })) {
  return null;                                              // passes: it IS under the root
}

normalizeAbsoluteFilePath only collapses ./../duplicate slashes; a segment named ~ is an ordinary segment. So the joined string is "inside" the workspace and survives every downstream containment check. Why the symptom follows, surface by surface (all in apps/app):

Deeper point. The renderer genuinely has no home directory to expand ~ against (the workspace may be on a remote machine, and no host-daemon field carries $HOME), so it cannot do better than "not a workspace file" without a protocol change. Option 2 in the issue (expand via host home) would need a new host payload field and a HOST_DAEMON_PROTOCOL_VERSION bump per AGENTS.md; option 1 is purely renderer-local. History: the resolver was added in 75327ea54 / e4040ab19 (2026-05/06) with no tilde case from the start; this is an omission, not a regression.

Proposed fix (first principles)

Reject shell-style home paths in resolveRelativeLocalFileHref after decoding, in the same guard clause as absolute paths. One helper + one condition; verified in this worktree (repro test goes 11/11 green, existing markdown-local-file-link.test.ts and markdown-preview.test.tsx still pass — 37 tests across the three files, turbo typecheck --filter=@bb/app clean, and the live app shows plain code / plain anchor — see last screenshot). Diff: 1182/repro/proposed-fix.diff (applies cleanly to 16ceb3a54 with git apply).

Correction (after verification): the first version of this report used /^~[^/]*(?:\/|$)/ and claimed it kept ~notes.md-style names linkified. That was wrong — [^/]* swallows the whole first segment and $ then always matches, so it was equivalent to /^~/. The regex below is the corrected one: it matches a bare ~, or a first segment starting with ~ that is followed by a slash (~/…, ~user/…), and nothing else. Table from node (verify/revise-regex.txt):

"~"              true    (rejected: home path)
"~/x"            true
"~user/x.md"     true
"~foo/bar.md"    true
"~notes.md"      false   (kept: relative file name)
"~$report.docx"  false
"~notes.md:3"    false   (line suffix is parsed off before the check anyway)
"docs/~draft.md" false
+// Shell-style home paths (`~`, `~/x`, `~user/x`) are not workspace-relative.
+// The renderer has no home directory to expand them against, so leave them as
+// plain links instead of joining the literal `~` onto the workspace root.
+// Only a bare `~` or a first segment that starts with `~` and is followed by a
+// `/` counts; a plain file name that happens to start with `~` (for example
+// `~notes.md` or a `~$report.docx` lock file) is still a relative file. This
+// runs on the *decoded* href, so `%7E/x` is covered too.
+function isHomeRelativePath(path: string): boolean {
+  return /^~(?:[^/]*\/|$)/u.test(path);
+}
 …
     parsedHref.path.startsWith("/") ||
+    isHomeRelativePath(parsedHref.path) ||
     parsedHref.path.startsWith("#") ||

Notes / what could go wrong:

Related issues

Appendix

Files

Commands run

pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
git fetch origin main; git log 16ceb3a54..origin/main --oneline -- apps/app/src/components/ui/markdown-local-file-link.ts   # (empty)
git checkout 16ceb3a54   # worktree had drifted to a108fa7ef (5 unrelated commits); pinned back to base
cd apps/app && pnpm exec vitest run src/components/ui/markdown-local-file-link.issue-1182.test.ts --reporter=verbose
scripts/bb-dev-app current
export BB_SERVER_URL=http://localhost:25374 BB_HOST_DAEMON_PORT=33374
pnpm bb:dev machine list
curl -s -X POST $BB_SERVER_URL/api/v1/projects ... (see step B1)
node packages/scripts/dist/commands/run-cli.js thread spawn/tell/wait/output ... (see step B2)
dev-browser --browser bb1182b --headless --timeout 90 run /tmp/bb-reports/issues/1182/repro/browser-step{1,3,4,5-with-fix}.js
# with fix applied:
cd apps/app && pnpm exec vitest run src/components/ui/markdown-local-file-link.issue-1182.test.ts src/components/ui/markdown-local-file-link.test.ts src/components/ui/markdown-preview.test.tsx   # 31 passed (first version of the repro test)
pnpm exec turbo run typecheck --filter=@bb/app   # ok
git checkout apps/app/src/components/ui/markdown-local-file-link.ts   # reverted; diff saved
pnpm dev:stop
# revision pass (after verifier findings), in worktree wf_6b6686dc-4c2-27 pinned to 16ceb3a54:
node -e '…regex table…' > /tmp/bb-reports/issues/1182/verify/revise-regex.txt
cd apps/app && pnpm exec vitest run src/components/ui/markdown-local-file-link.issue-1182.test.ts   # base: 7 failed | 4 passed
git apply /tmp/bb-reports/issues/1182/repro/proposed-fix.diff
cd apps/app && pnpm exec vitest run …issue-1182.test.ts …markdown-local-file-link.test.ts …markdown-preview.test.tsx   # 37 passed (verify/revise-vitest-fix.txt)
pnpm exec turbo run typecheck --filter=@bb/app   # ok (verify/revise-typecheck.txt)

Raw: DOM link hrefs on base (step 3)

[
 { "text": "this link", "href": "~/.config/example.md" },                                   <- user's echoed prompt (not linkified: plain anchor)
 { "text": "this link", "href": "file:///tmp/bb-1182-scratch/~/.config/example.md" },        <- assistant message
 { "text": "~/.config/example.md", "href": "file:///tmp/bb-1182-scratch/~/.config/example.md" },
 { "text": "~/notes.md", "href": "file:///tmp/bb-1182-scratch/~/notes.md" }
]
toast text: "Failed to open file locally\nOpen target path does not exist: /tmp/bb-1182-scratch/~/notes.md"

Raw: DOM link hrefs with proposed fix (step 5)

[
 { "text": "this link", "href": "~/.config/example.md" },
 { "text": "this link", "href": "~/.config/example.md" }
]

Note on the CLI prompt in the first turn

The first spawn was sent through pnpm bb:dev thread spawn … --prompt '…`~/.config/example.md`…'; pnpm re-invokes through a shell, which performed command substitution on the backticks and stripped the inline code from the prompt (visible in the first user bubble). The explicit markdown link survived, which is what the first three screenshots exercise; the second turn (sent with run-cli.js directly) exercises the inline-code form.

Verification

An independent verifier followed this report in a fresh worktree at 16ceb3a54: (A) the unit test reproduced with the same assertions, and the fix diff applied cleanly and turned the suite green; (B) a live dev instance (App :14920 / Server :22920, project /tmp/bb-1182-verify-scratch, a real claude-code turn) showed the file:///tmp/bb-1182-verify-scratch/~/.config/example.md anchor, the "Failed to load file" tab, and the "Open target path does not exist" toast; all permalinked code excerpts matched the base commit and origin/main still lacks a fix. Verifier logs and screenshots: verify/vitest-base.txt, verify/vitest-fix.txt, verify/1182-verify-thread.png, verify/1182-verify-after-click.png, verify/1182-verify-open-in-editor.png.

Findings and what changed in this revision:

The live-app screenshots were not re-taken for the corrected regex: the only behavioural difference between the two regexes is for names like ~notes.md (kept as workspace files, unchanged from base), and every ~/… case exercised in the browser is rejected identically by both, as the unit test shows.