mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
fix(dev-host): reload manifest.json instead of failing the identity check
The dev host read manifest.json once at startup and froze that copy in the Sidecar, while the backend watcher happily rebuilt the binary on any source edit. Editing the manifest while the host ran — a routine version bump during a release, for example — then made every restart compare the fresh binary against the stale in-memory manifest and fail with "Sidecar identity or protocol does not match manifest" until the whole dev host was restarted. Two layers of fixing: the project root now watches manifest.json and hot swaps the in-memory copy (plus secret-key collection), and the sidecar handshake re-reads the manifest from disk before declaring a mismatch, so the check can no longer race the watcher. A genuinely disagreeing manifest still fails the handshake.
This commit is contained in:
@@ -40,11 +40,16 @@ function localDocumentAssetPath(url, entry) {
|
||||
export async function createMockHost(options) {
|
||||
const diagnostics = options.diagnostics || new Diagnostics();
|
||||
const project = resolve(options.project),
|
||||
manifest = JSON.parse(await readFile(resolve(project, "manifest.json"), "utf8"));
|
||||
for (const contribution of manifest.contributions || [])
|
||||
for (const field of contribution.fields || []) {
|
||||
if (field.type === "password" || ["password", "secret"].includes(field.binding)) diagnostics.secretKeys.add(field.key);
|
||||
}
|
||||
manifestPath = resolve(project, "manifest.json");
|
||||
let manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||
const refreshSecretKeys = () => {
|
||||
diagnostics.secretKeys.clear();
|
||||
for (const contribution of manifest.contributions || [])
|
||||
for (const field of contribution.fields || []) {
|
||||
if (field.type === "password" || ["password", "secret"].includes(field.binding)) diagnostics.secretKeys.add(field.key);
|
||||
}
|
||||
};
|
||||
refreshSecretKeys();
|
||||
if (manifest.manifest_version !== undefined && manifest.manifest_version !== 1) throw new Error("Unsupported manifest version");
|
||||
// DBX uses Rust semver requirements, whose comparator separators include commas.
|
||||
if (manifest.engines?.host_api && !semver.satisfies("1.0.0", manifest.engines.host_api.replaceAll(",", " "))) throw new Error("Plugin does not support Host API 1.0.0");
|
||||
@@ -62,7 +67,7 @@ export async function createMockHost(options) {
|
||||
throw new Error("Development data directory must be outside the UI resource root");
|
||||
}
|
||||
if (backendEntry && !options.backend) throw new Error("Backend executable is required");
|
||||
const sidecar = new Sidecar({ executable: backendEntry ? resolve(project, options.backend) : undefined, args: options.backendArgs || [], cwd: project, manifest, transport });
|
||||
const sidecar = new Sidecar({ executable: backendEntry ? resolve(project, options.backend) : undefined, args: options.backendArgs || [], cwd: project, manifest, manifestPath, transport });
|
||||
const connected = new Set(),
|
||||
frames = new Map(),
|
||||
sessions = new Map(),
|
||||
@@ -497,6 +502,33 @@ export async function createMockHost(options) {
|
||||
}
|
||||
}
|
||||
})();
|
||||
// manifest.json lives at the project root, outside the backend watch tree,
|
||||
// yet edits (version bumps, field changes) must reach this running host —
|
||||
// the sidecar identity check compares against this in-memory copy.
|
||||
let manifestDebounce;
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const event of watch(project, { signal: watchStop.signal })) {
|
||||
if (String(event.filename || "").replaceAll("\\", "/") !== "manifest.json") continue;
|
||||
clearTimeout(manifestDebounce);
|
||||
manifestDebounce = setTimeout(async () => {
|
||||
try {
|
||||
const fresh = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||
if (typeof fresh?.id !== "string" || typeof fresh?.version !== "string") throw new Error("Invalid manifest identity");
|
||||
if (fresh.manifest_version !== undefined && fresh.manifest_version !== 1) throw new Error("Unsupported manifest version");
|
||||
manifest = fresh;
|
||||
sidecar.manifest = fresh;
|
||||
refreshSecretKeys();
|
||||
diagnostics.record("info", "build", "manifest.json 已重新加载", { version: fresh.version });
|
||||
} catch (error) {
|
||||
diagnostics.record("error", "build", "manifest.json 重新加载失败", { reason: String(error.message || error) });
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== "AbortError") diagnostics.record("error", "build", "manifest 监听已停止");
|
||||
}
|
||||
})();
|
||||
let debounce;
|
||||
void (async () => {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { stopProcessTree } from "./process-tree.mjs";
|
||||
|
||||
export const JSON_LIMIT = 8 * 1024 * 1024;
|
||||
@@ -71,11 +73,29 @@ export class Sidecar extends EventEmitter {
|
||||
pending = new Map();
|
||||
sequence = 0;
|
||||
state = "stopped";
|
||||
constructor({ executable, args = [], cwd, manifest, transport }) {
|
||||
constructor({ executable, args = [], cwd, manifest, manifestPath, transport }) {
|
||||
super();
|
||||
Object.assign(this, { executable, args, cwd, manifest });
|
||||
Object.assign(this, { executable, args, cwd, manifest, manifestPath });
|
||||
this.transport = transport || manifest.entrypoints?.backend?.transport || "stdio-jsonl";
|
||||
}
|
||||
identityMatches(info) {
|
||||
return info?.protocolVersion === 1 && info?.plugin?.id === this.manifest.id && info?.plugin?.version === this.manifest.version;
|
||||
}
|
||||
// The manifest is a source file during development: version bumps and field
|
||||
// edits made after this host started leave the in-memory copy stale. Re-read
|
||||
// it from disk before declaring a freshly spawned sidecar incompatible.
|
||||
async refreshManifestFromDisk() {
|
||||
const path = this.manifestPath || (this.cwd ? join(this.cwd, "manifest.json") : "");
|
||||
if (!path) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(path, "utf8"));
|
||||
if (typeof parsed?.id !== "string" || typeof parsed?.version !== "string") return false;
|
||||
this.manifest = parsed;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async start() {
|
||||
if (!this.executable) {
|
||||
this.state = "frontend";
|
||||
@@ -126,8 +146,10 @@ export class Sidecar extends EventEmitter {
|
||||
});
|
||||
try {
|
||||
const info = await this.request("plugin/initialize", { host: { protocolVersions: [1] } }, 10000);
|
||||
if (info?.protocolVersion !== 1 || info?.plugin?.id !== this.manifest.id || info?.plugin?.version !== this.manifest.version) {
|
||||
throw new Error("Sidecar identity or protocol does not match manifest");
|
||||
if (!this.identityMatches(info)) {
|
||||
const refreshed = await this.refreshManifestFromDisk();
|
||||
if (!refreshed || !this.identityMatches(info)) throw new Error("Sidecar identity or protocol does not match manifest");
|
||||
this.emit("diagnostic", { level: "info", message: "manifest.json 已重新加载", details: { version: this.manifest.version } });
|
||||
}
|
||||
this.state = "ready";
|
||||
this.emit("status", this.state);
|
||||
|
||||
@@ -3,6 +3,9 @@ import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { once } from "node:events";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { FrameDecoder, JsonLineDecoder, frame, binaryPayload, Sidecar, JSON_LIMIT } from "../sidecar.mjs";
|
||||
|
||||
const config = { executable: process.execPath, args: [fileURLToPath(new URL("./echo-sidecar.mjs", import.meta.url))], transport: "stdio-framed", manifest: { id: "example.echo", version: "1.0.0" } };
|
||||
@@ -88,6 +91,28 @@ test("handshake rejects wrong identity and missing executables terminate promptl
|
||||
await missing.stop();
|
||||
});
|
||||
|
||||
test("handshake adopts a manifest.json rewritten on disk when the in-memory copy is stale", async (t) => {
|
||||
const project = await mkdtemp(join(tmpdir(), "dbx-sidecar-manifest-"));
|
||||
t.after(() => rm(project, { recursive: true, force: true }));
|
||||
// Simulates a version bump made while the dev host was already running:
|
||||
// the sidecar binary reports 1.0.0, the in-memory manifest is behind.
|
||||
await writeFile(join(project, "manifest.json"), JSON.stringify({ id: "example.echo", version: "1.0.0" }));
|
||||
const sidecar = new Sidecar({ ...config, cwd: project, manifest: { id: "example.echo", version: "0.9.0" } });
|
||||
t.after(() => sidecar.stop());
|
||||
const info = await sidecar.start();
|
||||
assert.equal(sidecar.state, "ready");
|
||||
assert.equal(sidecar.manifest.version, "1.0.0");
|
||||
assert.equal(info.plugin.version, "1.0.0");
|
||||
});
|
||||
|
||||
test("handshake still rejects when the on-disk manifest also disagrees with the sidecar", async (t) => {
|
||||
const project = await mkdtemp(join(tmpdir(), "dbx-sidecar-manifest-"));
|
||||
t.after(() => rm(project, { recursive: true, force: true }));
|
||||
await writeFile(join(project, "manifest.json"), JSON.stringify({ id: "example.echo", version: "0.9.0" }));
|
||||
const sidecar = new Sidecar({ ...config, cwd: project, manifest: { id: "example.echo", version: "0.9.0" } });
|
||||
await assert.rejects(sidecar.start(), /identity/);
|
||||
});
|
||||
|
||||
test("JSONL supports split lines, concurrent RPC, events and rejects binary channels", async (t) => {
|
||||
const packets = [],
|
||||
decoder = new JsonLineDecoder((packet) => packets.push(packet));
|
||||
|
||||
@@ -454,7 +454,19 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<iframe v-for="f in frames" v-show="active === f.id" :key="f.id" :ref="(el) => (el ? windows.set(f.id, el) : windows.delete(f.id))" :srcdoc="f.html" sandbox="allow-scripts" allow="clipboard-write" referrerpolicy="no-referrer" :title="frameName(f)" class="absolute inset-0" @load="init(f)" />
|
||||
<iframe
|
||||
v-for="f in frames"
|
||||
v-show="active === f.id"
|
||||
:key="f.id"
|
||||
:ref="(el) => (el ? windows.set(f.id, el) : windows.delete(f.id))"
|
||||
:srcdoc="f.html"
|
||||
sandbox="allow-scripts"
|
||||
allow="clipboard-write"
|
||||
referrerpolicy="no-referrer"
|
||||
:title="frameName(f)"
|
||||
class="absolute inset-0"
|
||||
@load="init(f)"
|
||||
/>
|
||||
<div v-if="!frames.length" class="grid h-full place-items-center text-sm text-base-content/50">
|
||||
<div class="flex items-center gap-2"><Settings2 :size="18" />{{ t("尚未打开工作台") }}</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user