diff --git a/plugins/sdk/dev-host/server.mjs b/plugins/sdk/dev-host/server.mjs index 6494fc49c7..35f93d4e15 100644 --- a/plugins/sdk/dev-host/server.mjs +++ b/plugins/sdk/dev-host/server.mjs @@ -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 { diff --git a/plugins/sdk/dev-host/sidecar.mjs b/plugins/sdk/dev-host/sidecar.mjs index 645ee25907..172cfd64dc 100644 --- a/plugins/sdk/dev-host/sidecar.mjs +++ b/plugins/sdk/dev-host/sidecar.mjs @@ -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); diff --git a/plugins/sdk/dev-host/tests/sidecar.test.mjs b/plugins/sdk/dev-host/tests/sidecar.test.mjs index 68ad17e976..8fb23887fe 100644 --- a/plugins/sdk/dev-host/tests/sidecar.test.mjs +++ b/plugins/sdk/dev-host/tests/sidecar.test.mjs @@ -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)); diff --git a/plugins/sdk/dev-host/ui/App.vue b/plugins/sdk/dev-host/ui/App.vue index df0b9db2df..906afc1938 100644 --- a/plugins/sdk/dev-host/ui/App.vue +++ b/plugins/sdk/dev-host/ui/App.vue @@ -454,7 +454,19 @@ onBeforeUnmount(() => {