Export preserves plugin file references without carrying external files or warning
2026-09-30 · Bug · Medium priority · High effort
PARTIALLY REPRODUCED · High confidence in the tested export/extraction behavior.
Claim and tested scope
A plugin setting may refer to a server-local file outside the server data directory. The migration should identify unresolved references so the operator can handle them explicitly. This fixture creates one owned temporary root containing a source data directory, an external sibling file, export work directory and extraction destination. It stores synthetic absolute paths through the real plugin-settings database API, runs the actual exportServerArchive, extracts the actual archive and reads settings from the extracted SQLite database.
In both clean runs, the external file is absent from the manifest, settings remain byte-for-byte equivalent as values, the internal control file is included, skippedPaths is empty and the export logger receives zero warnings. The expected warning assertion fails after the inclusion/settings controls pass. The same agent personally repeated the test in a second clean checkout at the same SHA.
This is export plus extraction, not a complete server move. No remote host, Connect tunnel, package download, destination activation, final import reconciliation, plugin runtime or CLI --check was executed. In particular, the extracted internal-file setting still points at the source, but the report does not establish what a later import stage might rewrite. External omission is demonstrated directly; the target plugin’s eventual failure is not exercised. These limits make the verdict partial.
Expected and observed
Expected tested behavior: detect or report the external path-valued setting rather than silently producing an archive that preserves its reference but omits its content. Observed: zero export warnings, no skipped path entry, external content absent, unchanged settings. The internal control file’s contents are present at the extraction destination, showing this is not a generally empty or failed archive.
After export, the test removes only its own synthetic external source file, then verifies that the preserved absolute reference no longer exists. This is an explicit local simulation of losing access to the source file, not proof about a real target filesystem. The test uses plain .txt files with marker text, not credentials, secrets or a real plugin’s configuration.
Trusted base and environment
Fetched public origin/main: 4195cf56febd44853463cd9eec518a172dd7654a. Linux x86_64, Node 24.19.0, pinned pnpm 9.15.0, Vitest 4.1.1, real migrated SQLite databases and real server archive functions. Two clean detached worktrees, separate frozen dependency installations/caches and fresh temporary data trees; shared dependency download store. Both upstream builds succeeded. Each focused test intentionally exits 1 at the expected-warning assertion. No added dependencies or production changes.
Root cause and proposal
Export snapshots the main database and inventories server-owned files. The inventory walks designated roots beneath dataDir, not arbitrary file references inside settings. Export warnings concern skipped symbolic links and special files. Plugin settings are stored as string values and are carried by the database snapshot. No semantic file-reference inspection occurs in this tested export path.
Read-only source inspection also shows the preflight target-path list uses folder-installed plugin sources and managed environment paths. That observation is not a substitute for running CLI --check, and no runtime conclusion about its full output is claimed here.
Proposed fix: define explicit server-file setting metadata and include those references in migration preflight/checklists. Warn about unavailable target files and distinguish copied data-directory files from external references. Rebase supported internal paths during import if appropriate. Any external-file transfer should be explicit and controlled; do not silently copy arbitrary files merely because a string resembles a path. Add export/import and preflight tests with synthetic references. No fix or PR was created.
Repeatable steps
Save the inline patch below as regression.patch outside each checkout. The patch uses the trusted test file’s makeTempDir cleanup helper. Every file it creates, copies or removes is inside its newly created temporary root. Use repository-pinned pnpm 9.15.0. Commands retain the executed flags with local directory names normalized.
git clone https://github.com/get-bb/bb.git first cd first git checkout --detach 4195cf56febd44853463cd9eec518a172dd7654a pnpm install --frozen-lockfile --store-dir ../dependency-store git apply ../regression.patch pnpm exec turbo run test --filter=@bb/server --force -- test/server-move/export.test.ts -t issue4420 cd .. git clone https://github.com/get-bb/bb.git second cd second git checkout --detach 4195cf56febd44853463cd9eec518a172dd7654a pnpm install --frozen-lockfile --store-dir ../dependency-store git apply ../regression.patch pnpm exec turbo run test --filter=@bb/server --force -- test/server-move/export.test.ts -t issue4420
Complete regression patch
diff --git a/apps/server/test/server-move/export.test.ts b/apps/server/test/server-move/export.test.ts
index 1b347d0c5..b62d99855 100644
--- a/apps/server/test/server-move/export.test.ts
+++ b/apps/server/test/server-move/export.test.ts
@@ -10,7 +10,7 @@ import {
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
-import { createConnection, getHost, setExperiments } from "@bb/db";
+import { migrate, getPluginSettingsValues, setPluginSettingsValues, createConnection, getHost, setExperiments } from "@bb/db";
import { defaultExperiments } from "@bb/domain";
import { HOST_DAEMON_PROTOCOL_VERSION } from "@bb/host-daemon-contract";
import {
@@ -201,3 +201,41 @@ describe("server archive export", () => {
expect(manifest.serverMoveExperiment).toBe(true);
}));
});
+
+
+it("issue4420 reports external setting files during export", async () => {
+ const root=await makeTempDir();
+ const dataDir=join(root,"source-data");
+ const external=join(root,"external","synthetic-reference.txt");
+ const internal=join(dataDir,"plugins","synthetic-fixture","included.txt");
+ await mkdir(dataDir,{recursive:true});
+ await mkdir(dirname(external),{recursive:true});
+ await mkdir(dirname(internal),{recursive:true});
+ await writeFile(external,"SYNTHETIC_EXTERNAL_CONTENT");
+ await writeFile(internal,"SYNTHETIC_INTERNAL_CONTENT");
+ const db=createConnection(join(dataDir,"bb.db"));
+ migrate(db);
+ const values={externalFile:external,internalFile:internal,plainText:"synthetic-value"};
+ setPluginSettingsValues(db,"synthetic-fixture",values);
+ const warn=vi.fn();
+ try {
+ const archive=await exportServerArchive({appVersion:"0.0.0",dataDir,db,fileName:"fixture.tar.gz",logger:{warn},now:1800000000000,sourceServerHostId:null,workDir:join(root,"work")});
+ const destination=join(root,"destination");
+ const manifest=await extractServerArchive({archivePath:archive.path,destinationDir:destination});
+ const imported=createConnection(join(destination,"files","bb.db"));
+ try {
+ const restored=getPluginSettingsValues(imported,"synthetic-fixture");
+ const copiedInternal=join(destination,"files","plugins","synthetic-fixture","included.txt");
+ const internalContent=await readFile(copiedInternal,"utf8");
+ const externalIncluded=manifest.entries.some(e=>e.path.endsWith("synthetic-reference.txt"));
+ expect(restored).toEqual(values);
+ expect(internalContent).toBe("SYNTHETIC_INTERNAL_CONTENT");
+ expect(externalIncluded).toBe(false);
+ expect(archive.skippedPaths).toEqual([]);
+ await rm(external);
+ const observation={settingsCopiedVerbatim:JSON.stringify(restored)===JSON.stringify(values),externalIncluded,externalReferenceExistsAfterRemovingOwnedSource:existsSync(restored.externalFile!),internalFileCopied:internalContent==="SYNTHETIC_INTERNAL_CONTENT",internalSettingStillPointsToSource:restored.internalFile===internal,internalSettingRebasedToDestination:restored.internalFile===copiedInternal,exportWarnings:warn.mock.calls.length,skippedPaths:archive.skippedPaths};
+ console.log("ISSUE4420 "+JSON.stringify(observation));
+ expect(warn.mock.calls.length).toBeGreaterThan(0);
+ } finally {imported.$client.close();}
+ } finally {db.$client.close();}
+});
Actual evidence and same-agent second clean reproduction
Both runs reached the final warning assertion after all preceding content/settings controls passed. Each reports one expected failure and two unrelated skipped tests. Both outputs are identical. This is same-agent repetition in a second clean checkout, not independent verification.
first clean run
@bb/server:test: ISSUE4420 {"settingsCopiedVerbatim":true,"externalIncluded":false,"externalReferenceExistsAfterRemovingOwnedSource":false,"internalFileCopied":true,"internalSettingStillPointsToSource":true,"internalSettingRebasedToDestination":false,"exportWarnings":0,"skippedPaths":[]}
@bb/server:test: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
@bb/server:test: AssertionError: expected 0 to be greater than 0
@bb/server:test: Test Files 1 failed (1)
@bb/server:test: Tests 1 failed | 2 skipped (3)
@bb/server:test: Duration 8.39s (transform 4.95s, setup 1.61s, import 4.90s, tests 1.71s, environment 0ms)
second clean run
@bb/server:test: ISSUE4420 {"settingsCopiedVerbatim":true,"externalIncluded":false,"externalReferenceExistsAfterRemovingOwnedSource":false,"internalFileCopied":true,"internalSettingStillPointsToSource":true,"internalSettingRebasedToDestination":false,"exportWarnings":0,"skippedPaths":[]}
@bb/server:test: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
@bb/server:test: AssertionError: expected 0 to be greater than 0
@bb/server:test: Test Files 1 failed (1)
@bb/server:test: Tests 1 failed | 2 skipped (3)
@bb/server:test: Duration 5.78s (transform 3.47s, setup 1.22s, import 3.60s, tests 806ms, environment 0ms)
Trust boundary and remaining work
Issue text, commands, settings examples and external links were treated as untrusted claims; none was executed or used as a filesystem argument. The test was derived from trusted main’s export tests and database/archive interfaces. It uses synthetic non-sensitive content only. No real external/user files, credentials, hosts or application runtime were accessed. This is ordinary migration correctness, not an exploit or security claim. Full preflight and post-import behavior remain unverified; previous blocked issues remain untouched.