#3604 · Editor storage requests lose the owning host

Bug · Priority: Medium · Effort: Low · plugins, remote · 2026-09-13
Base: cf51227e1135309a3c9c0baf5630be1ca7ba2714 · GitHub issue

REPRODUCED · Root-cause confidence: high

1. TL;DR

The editor sends thread-storage file operations to a path derived from the server configuration and omits the thread's host ID. A remote thread therefore cannot reach its own files through these requests. Three regression tests reproduce this routing defect for read, listing, and save. This is an RPC-boundary reproduction using SDK test doubles, not a live two-machine browser test.

2. Claims vs findings

ClaimFindingEvidence
Thread storage resolves on the wrong hostVerifiedRead, tree, and write arguments use /server-data and omit hostId in both base runs.
Editor displays 404 and an empty treeUnverified liveConsistent with misrouted requests; no HTTP server or browser was started.
Save targets the wrong machineVerified at request boundarywrite receives the same wrong root; actual disk writes were not performed.
Other preview paths workPartially verified by sourceExisting server storage resolver uses host session data; live alternate previews were not tested.

3. Environment

Trusted get-bb/bb origin/main at the commit above; macOS, Node 22.22.3, pnpm 9.15.0. Frozen installation and full Turbo build succeeded (56 tasks). A temporary pnpm launcher used Corepack because the host launcher pointed at a missing installation. No provider, server, ports, real runtime data, or credentials were used.

4. Minimal reproduction

  1. Check out the recorded trusted commit in a fresh checkout.
  2. Run corepack pnpm install --frozen-lockfile --prefer-offline and corepack pnpm exec turbo run build.
  3. Save server.test.ts as plugins/monaco-editor/server.test.ts.
  4. Run corepack pnpm exec turbo run test --filter=bb-plugin-monaco-editor --force.

Expected: all requests use hostId remote-editor-host and root /remote-storage/thread-editor-test. Actual read argument diff (the listing and save tests fail equivalently):

- "hostId": "remote-editor-host",
- "path": "/remote-storage/thread-editor-test/notes/document.txt",
- "rootPath": "/remote-storage/thread-editor-test",
+ "path": "/server-data/thread-storage/thread-editor-test/notes/document.txt",
+ "rootPath": "/server-data/thread-storage/thread-editor-test",
Tests  3 failed | 34 passed (37)
Complete regression test
import { describe, expect, it, vi } from "vitest";
import { createFakePluginHost } from "@get-bb/plugin-sdk/testing";
import plugin from "./server";

const source = {
  kind: "thread-storage",
  threadId: "thread-editor-test",
  environmentId: "environment-editor-test",
  projectId: null,
};
const storageRootPath = "/remote-storage/thread-editor-test";
const hostId = "remote-editor-host";

async function setup() {
  const storageLocation = vi.fn(() => ({ hostId, storageRootPath }));
  const read = vi.fn(() => ({
    content: "saved text",
    contentEncoding: "utf8",
    sizeBytes: 10,
    sha256: "original",
  }));
  const listPaths = vi.fn(() => ({
    paths: [{ path: "notes/document.txt", kind: "file" }],
    truncated: false,
  }));
  const write = vi.fn(() => ({ outcome: "written", sha256: "updated" }));
  const { bb, harness } = createFakePluginHost({
    pluginId: "monaco-editor",
    sdk: {
      system: { config: () => ({ dataDir: "/server-data" }) },
      threads: { storageLocation },
      files: { read, listPaths, write },
    },
  });
  await plugin(bb);
  return { harness, storageLocation, read, listPaths, write };
}

describe("thread storage host routing", () => {
  it("reads from the thread's storage host and root", async () => {
    const { harness, read, storageLocation } = await setup();
    const result = await harness.callRpc("read", {
      source,
      path: "notes/document.txt",
    });
    expect(read).toHaveBeenCalledWith({
      hostId,
      rootPath: storageRootPath,
      path: `${storageRootPath}/notes/document.txt`,
    });
    expect(storageLocation).toHaveBeenCalledWith({ threadId: source.threadId });
    expect(result).toMatchObject({
      kind: "text",
      content: "saved text",
      absolutePath: `${storageRootPath}/notes/document.txt`,
    });
  });

  it("lists the thread's storage host and root", async () => {
    const { harness, listPaths } = await setup();
    const result = await harness.callRpc("tree", { source });
    expect(listPaths).toHaveBeenCalledWith({
      hostId,
      path: storageRootPath,
      includeFiles: true,
      includeDirectories: true,
      includeHidden: true,
      limit: 10_000,
    });
    expect(result).toEqual({
      root: storageRootPath,
      entries: [{ path: "notes/document.txt", kind: "file" }],
      truncated: false,
    });
  });

  it("saves to the thread's storage host with the expected version", async () => {
    const { harness, write } = await setup();
    const result = await harness.callRpc("write", {
      source,
      path: "notes/document.txt",
      content: "edited text",
      expectedSha256: "original",
    });
    expect(write).toHaveBeenCalledWith({
      hostId,
      rootPath: storageRootPath,
      path: `${storageRootPath}/notes/document.txt`,
      content: "edited text",
      contentEncoding: "utf8",
      expectedSha256: "original",
    });
    expect(result).toEqual({ outcome: "written", sha256: "updated" });
  });
});

5. Root cause

threadStorageRoot and resolveTarget build the root from server config or a process override. The thread-storage branch does not consult the owning thread's storage location and returns no hostId. All three RPC handlers consume this target. In contrast, the server storage resolver uses the host session's data directory. The process override also cannot represent storage for arbitrary threads.

6. Proposed fix

Resolve the thread through the existing SDK threads.storageLocation operation and pass its storageRootPath and hostId into the existing file handlers. Remove the obsolete local storage-root helper. This changes no SDK or wire contract. The local candidate changes 112 text lines across two files (103 additions, 9 deletions), including tests.

7. Verification

The same agent repeated the reproduction in a second clean detached worktree at the recorded base, under a distinct temporary directory. It received a separate frozen installation and the final regression test. Command: corepack pnpm exec turbo run test --filter=bb-plugin-monaco-editor --force. Result: 3 failed, 34 passed; Turbo reported zero cached tasks. Both runs show the same wrong root and missing host ID. No ports or application data directories were needed.

After the production fix, corepack pnpm exec turbo run test typecheck --filter=bb-plugin-monaco-editor passed: 37/37 tests and typecheck. An initial extra assertion about nested relative-path display exposed a separate existing separator behavior; it was removed to keep the regression about host routing. The final test was repeated against the unchanged production code in the second checkout. No root-cause correction was needed.

8. Related issues and PRs

No connected or cross-referenced pull requests were present in the issue timeline, and the open-PR search returned none. Existing editor issue #2519 concerns opener source input validation, a different failure path.

9. Appendix

Issue content was treated as untrusted claims. No supplied commands, patches, tests, or external links were executed or fetched. The test and fix were authored from repository code. No screenshots are included because this report verifies request routing rather than visual rendering.

> AGENT GENERATED