diff --git a/memhub/apps/desktop/src/host-control-ownership.test.ts b/memhub/apps/desktop/src/host-control-ownership.test.ts index 67b6be5..993fd5f 100644 --- a/memhub/apps/desktop/src/host-control-ownership.test.ts +++ b/memhub/apps/desktop/src/host-control-ownership.test.ts @@ -15,6 +15,7 @@ const fake = vi.hoisted(() => ({ missingBroker: false, hostCapability: true, open: vi.fn(), + refreshHosts: vi.fn(async (_layouts: unknown, _options: unknown) => ({})), host: vi.fn< (input: { hostId: string; @@ -274,7 +275,9 @@ beforeEach(async () => { fake.open.mockResolvedValue({ hostGrantStore: {}, repairInstalledCommand: vi.fn(async () => []), + refreshInstalledHosts: fake.refreshHosts, }); + fake.refreshHosts.mockClear(); fake.host.mockResolvedValue({ state: "synthetic-remote-result" }); Object.defineProperty(process, "resourcesPath", { configurable: true, @@ -590,5 +593,11 @@ describe("packaged Desktop host-control ownership", () => { expect(fake.ensureRuntime).not.toHaveBeenCalled(); expect(fake.createClient).not.toHaveBeenCalled(); expect(fake.windows).toHaveLength(1); + // The embedded Runtime is this process, so Desktop refreshes the hosts + // it installed once the Runtime is up, as a standalone one would itself. + expect(fake.refreshHosts).toHaveBeenCalledTimes(1); + expect(fake.refreshHosts.mock.calls[0]?.[1]).toMatchObject({ + diagnostics: expect.anything(), + }); }); }); diff --git a/memhub/apps/desktop/src/host-permissions.test.ts b/memhub/apps/desktop/src/host-permissions.test.ts index 6593032..f2be754 100644 --- a/memhub/apps/desktop/src/host-permissions.test.ts +++ b/memhub/apps/desktop/src/host-permissions.test.ts @@ -21,6 +21,8 @@ import { claudeCodeLayout, codexLayout, cursorLayout, + MCP_TOOLSET_KEY, + MCP_TOOLSET_VERSION, mcpConfigPath, piLayout, planRecallInstall, @@ -29,6 +31,7 @@ import { workbuddyLayout, workbuddyScatteredLayout, } from "@one-memory/host-bridge/installer"; +import type { DiagnosticInput } from "@one-memory/runtime-control"; import { afterEach, describe, expect, it, vi } from "vitest"; import { probeRecallInstall } from "../../../packages/host-bridge/src/recall-probe.js"; import { skillFilePath } from "../../../packages/host-bridge/src/skill-drift.js"; @@ -1384,3 +1387,197 @@ describe("upgrading installs left by an earlier release", () => { ); }); }); + +describe("refreshing installed hosts after an upgrade", () => { + function recorder() { + const events: DiagnosticInput[] = []; + return { + events, + sink: { + log: async (event: DiagnosticInput) => { + events.push(event); + }, + }, + }; + } + + async function linkedWorkbuddy(context: Awaited>) { + const layout = workbuddyLayout(path.join(context.root, "workbuddy")); + const preview = await context.permissions.previewInstall(layout); + const result = await context.permissions.install( + layout, + preview.fingerprint, + ); + await context.permissions.setPermission( + layout, + approval(required(result.permission.receipt)), + ); + return layout; + } + + /** Put back the skill an earlier release wrote, journaled as ours. */ + async function ageSkill(layout: ReturnType) { + const skillPath = required(skillFilePath(layout)); + const aged = (await readFile(skillPath, "utf8")) + .replace(/skill-version=\d+/, "skill-version=15") + .replace("`MemHub_recall`", "`MemHub_ask`"); + await writeFile(skillPath, aged); + const journal = JSON.parse(await readFile(layout.journalPath, "utf8")); + journal.installed_skill_hash = createHash("sha256") + .update(aged) + .digest("hex"); + await writeFile(layout.journalPath, JSON.stringify(journal)); + return aged; + } + + it("rewrites an old skill on the first start of a build, and only once per build", async () => { + const context = await fixture(); + const layout = await linkedWorkbuddy(context); + const current = await readFile(required(skillFilePath(layout)), "utf8"); + await ageSkill(layout); + const { events, sink } = recorder(); + + const first = await context.permissions.refreshInstalledHosts([layout], { + build: "34", + diagnostics: sink, + }); + expect(first).toMatchObject({ + state: "refreshed", + synced: ["workbuddy"], + failed: [], + }); + expect(await readFile(required(skillFilePath(layout)), "utf8")).toBe( + current, + ); + + // Same build again: one file read, nothing rewritten, even if stale. + const aged = await ageSkill(layout); + expect( + await context.permissions.refreshInstalledHosts([layout], { + build: "34", + diagnostics: sink, + }), + ).toMatchObject({ state: "already_refreshed", synced: [] }); + expect(await readFile(required(skillFilePath(layout)), "utf8")).toBe(aged); + + // The next build refreshes again. + expect( + await context.permissions.refreshInstalledHosts([layout], { + build: "35", + }), + ).toMatchObject({ state: "refreshed", synced: ["workbuddy"] }); + expect(await readFile(required(skillFilePath(layout)), "utf8")).toBe( + current, + ); + + // Diagnostics carry host ids and counts only, never file content. + expect(events.map((event) => event.event)).toEqual([ + "host_refresh.host_updated", + "host_refresh.completed", + "host_refresh.skipped", + ]); + expect(events[0].details).toEqual({ source_id: "workbuddy" }); + expect(events[1].details).toEqual({ + count: 1, + updated_count: 1, + error_count: 0, + }); + expect(JSON.stringify(events)).not.toContain("MemHub_"); + expect(JSON.stringify(events)).not.toContain(context.root); + }); + + it("leaves a skill the user edited untouched", async () => { + const context = await fixture(); + const layout = await linkedWorkbuddy(context); + const edited = `${await ageSkill(layout)}\nUser addition\n`; + await writeFile(required(skillFilePath(layout)), edited); + + expect( + await context.permissions.refreshInstalledHosts([layout], { + build: "34", + }), + ).toMatchObject({ state: "refreshed", synced: [], failed: [] }); + expect(await readFile(required(skillFilePath(layout)), "utf8")).toBe( + edited, + ); + }); + + it("rewrites an MCP entry without the tool set stamp, in the plugin manifest too", async () => { + const context = await fixture(); + const layout = await linkedWorkbuddy(context); + const plugin = required(layout.plugin); + const manifests = [ + path.join(plugin.installRoot, ".codebuddy-plugin", "plugin.json"), + path.join( + plugin.marketplaceRoot, + "plugins", + plugin.pluginName, + ".codebuddy-plugin", + "plugin.json", + ), + ]; + // What an earlier release wrote: the same entry with no stamp, journaled. + const journal = JSON.parse(await readFile(layout.journalPath, "utf8")); + for (const manifestPath of manifests) { + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + expect(manifest.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]).toBe( + MCP_TOOLSET_VERSION, + ); + delete manifest.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]; + const text = `${JSON.stringify(manifest, null, 2)}\n`; + await writeFile(manifestPath, text); + const entry = journal.plugin_files.find( + (file: { path: string }) => file.path === manifestPath, + ); + entry.hash = createHash("sha256").update(text).digest("hex"); + journal.installed_server_hash = createHash("sha256") + .update(JSON.stringify(manifest.mcpServers["one-memory"])) + .digest("hex"); + } + await writeFile(layout.journalPath, JSON.stringify(journal)); + const granted = await context.permissions.getPermission(layout); + expect(granted.state).toBe("approved"); + expect( + (await readInstalledRecallCommand(layout))?.env[MCP_TOOLSET_KEY], + ).toBeUndefined(); + + expect( + await context.permissions.refreshInstalledHosts([layout], { + build: "34", + }), + ).toMatchObject({ synced: ["workbuddy"], failed: [] }); + for (const manifestPath of manifests) { + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + expect(manifest.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]).toBe( + MCP_TOOLSET_VERSION, + ); + } + // Rewriting our own entry keeps the user's authorization. + expect(await context.permissions.getPermission(layout)).toEqual(granted); + }); + + it("rewrites a config-file MCP entry without the stamp", async () => { + const context = await fixture(); + await install(context); + const configPath = mcpConfigPath(context.layout); + const config = JSON.parse(await readFile(configPath, "utf8")); + delete config.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]; + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`); + const journal = JSON.parse( + await readFile(context.layout.journalPath, "utf8"), + ); + journal.installed_server_hash = createHash("sha256") + .update(JSON.stringify(config.mcpServers["one-memory"])) + .digest("hex"); + await writeFile(context.layout.journalPath, JSON.stringify(journal)); + + expect( + await context.permissions.refreshInstalledHosts([context.layout]), + ).toMatchObject({ state: "refreshed", synced: ["cursor"], failed: [] }); + const refreshed = JSON.parse(await readFile(configPath, "utf8")); + expect(refreshed.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]).toBe( + MCP_TOOLSET_VERSION, + ); + expect(refreshed.mcpServers.neighbor).toEqual({ command: "keep" }); + }); +}); diff --git a/memhub/apps/desktop/src/main.ts b/memhub/apps/desktop/src/main.ts index 48b3dfe..791a82a 100644 --- a/memhub/apps/desktop/src/main.ts +++ b/memhub/apps/desktop/src/main.ts @@ -1369,6 +1369,24 @@ async function initialize(isQuitting: () => boolean): Promise { }); await runtimeSupervisor.start(); if (isQuitting()) return; + // A standalone Runtime refreshes the hosts it installed as it starts, so + // an upgrade through the App or `memhub upgrade` is covered there. An + // embedded one is this process: do the same here, now it is up. Only a + // packaged build, so a source checkout never repoints real hosts at itself. + if (localPermissions && app.isPackaged) { + void localPermissions + .refreshInstalledHosts( + hosts.map((host) => host.layout), + { + build: + packagedBuildNumber === undefined + ? undefined + : String(packagedBuildNumber), + diagnostics: hostDiagnostics, + }, + ) + .catch(() => undefined); + } if ( app.isPackaged && process.platform === "darwin" && diff --git a/memhub/docs/contracts/one-memory-recall-install-v1.md b/memhub/docs/contracts/one-memory-recall-install-v1.md index 9fa6c8c..69d0c35 100644 --- a/memhub/docs/contracts/one-memory-recall-install-v1.md +++ b/memhub/docs/contracts/one-memory-recall-install-v1.md @@ -39,10 +39,12 @@ which talks to a broker on the same machine. | `ONE_MEMORY_BROKER_DISCOVERY` | absolute path to the discovery file | How the server finds the running app. Wrong value means recall returns nothing, with no error. | | `ONE_MEMORY_MANAGED_BY` | `one-memory` | Marks the entry as ours, so our installer will update or remove it later instead of treating it as a stranger's and leaving it alone. | | `ELECTRON_RUN_AS_NODE` | `1` | The MCP entry ships inside the app bundle and is started through the app's own binary. Without this the binary opens the window instead of running the script. | +| `ONE_MEMORY_MCP_TOOLSET` | tool set version, e.g. `3` | Names the MemHub tool set the entry was written for. MemHub changes it only when tools are added, removed or renamed, so hosts that cache a server's tool list by its launch config restart the server and drop removed tools. The server itself ignores it. | `ONE_MEMORY_MANAGED_BY` is optional for a hand-made entry. Set it if you want MemHub to manage the entry for you afterwards; leave it out if you would -rather own the entry yourself. +rather own the entry yourself. `ONE_MEMORY_MCP_TOOLSET` is optional too; a +managed entry without the current value is rewritten on the next upgrade. ## 3. MCP hosts @@ -57,7 +59,8 @@ Register a stdio server named `one-memory`. The product name is MemHub; the conf "env": { "ELECTRON_RUN_AS_NODE": "1", "ONE_MEMORY_BROKER_DISCOVERY": "", - "ONE_MEMORY_MANAGED_BY": "one-memory" + "ONE_MEMORY_MANAGED_BY": "one-memory", + "ONE_MEMORY_MCP_TOOLSET": "3" } } } diff --git a/memhub/packages/host-bridge/src/install-contract.test.ts b/memhub/packages/host-bridge/src/install-contract.test.ts index 7d67eae..f67e778 100644 --- a/memhub/packages/host-bridge/src/install-contract.test.ts +++ b/memhub/packages/host-bridge/src/install-contract.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it } from "vitest"; import type { RecallRuntime } from "./recall-installer.js"; import { DISCOVERY_KEY, + MCP_TOOLSET_KEY, + MCP_TOOLSET_VERSION, OWNER_KEY, OWNER_VALUE, renderConnectionMaterial, @@ -60,6 +62,7 @@ describe("mcp install snippet", () => { const env = parsed.mcpServers[SERVER_NAME].env; expect(env[DISCOVERY_KEY]).toBe(runtime().discoveryPath); expect(env[OWNER_KEY]).toBe(OWNER_VALUE); + expect(env[MCP_TOOLSET_KEY]).toBe(MCP_TOOLSET_VERSION); expect(env.ELECTRON_RUN_AS_NODE).toBe("1"); }); @@ -174,9 +177,10 @@ describe("public contract document", () => { // reports it to us. Same class of problem R-33 treated for skill text, // and treated the same way: one source, checked by a test. const doc = await fs.readFile(CONTRACT_DOC, "utf8"); - for (const key of [DISCOVERY_KEY, OWNER_KEY]) { + for (const key of [DISCOVERY_KEY, OWNER_KEY, MCP_TOOLSET_KEY]) { expect(doc).toContain(key); } + expect(doc).toContain(`"${MCP_TOOLSET_KEY}": "${MCP_TOOLSET_VERSION}"`); expect(doc).toContain(OWNER_VALUE); expect(doc).toContain(SERVER_NAME); }); diff --git a/memhub/packages/host-bridge/src/mcp-granted-tools.test.ts b/memhub/packages/host-bridge/src/mcp-granted-tools.test.ts index 3ac0305..cc625a2 100644 --- a/memhub/packages/host-bridge/src/mcp-granted-tools.test.ts +++ b/memhub/packages/host-bridge/src/mcp-granted-tools.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { type GrantedTools, McpSession } from "./mcp-session.js"; +import { MCP_TOOL_NAMES, MCP_TOOLSET_VERSION } from "./mcp-toolset.js"; function sessionWith(granted: GrantedTools): McpSession { return new McpSession( @@ -71,3 +72,14 @@ describe("BUG-15: the listed tools follow what the grant allows", () => { expect(names).toHaveLength(3); }); }); + +describe("the tool set stamped on installed entries", () => { + it("names exactly the tools the server lists", async () => { + // Change a tool without moving MCP_TOOLSET_VERSION and hosts that cache + // tool lists by config fingerprint keep showing the old set. Update both, + // and this pinned version, together. + const names = await listedNames(sessionWith(async () => null)); + expect(names).toEqual([...MCP_TOOL_NAMES]); + expect(MCP_TOOLSET_VERSION).toBe("3"); + }); +}); diff --git a/memhub/packages/host-bridge/src/mcp-session.ts b/memhub/packages/host-bridge/src/mcp-session.ts index 5626593..2c81095 100644 --- a/memhub/packages/host-bridge/src/mcp-session.ts +++ b/memhub/packages/host-bridge/src/mcp-session.ts @@ -127,6 +127,8 @@ export class McpSession { } private async listedTools() { + // Any change to this set moves MCP_TOOL_NAMES and MCP_TOOLSET_VERSION in + // `mcp-toolset.ts`, so installed entries change and hosts relist. const definitions = [ recallToolDefinition(), writeToolDefinition(), diff --git a/memhub/packages/host-bridge/src/mcp-toolset.ts b/memhub/packages/host-bridge/src/mcp-toolset.ts new file mode 100644 index 0000000..050ab9c --- /dev/null +++ b/memhub/packages/host-bridge/src/mcp-toolset.ts @@ -0,0 +1,29 @@ +/** The tool contract of MemHub's MCP server, and the stamp that names it. + * + * Kept apart from `mcp-session.ts` so the installer can stamp an entry without + * pulling in the broker client, and listed here rather than inferred so a test + * can hold `tools/list` to it: change a tool there and that test fails until + * this list, and with it the version, moves too. + */ + +/** The tools `tools/list` returns, in order. */ +export const MCP_TOOL_NAMES = [ + "MemHub_recall", + "MemHub_write", + "MemHub_diagnostic_trace", +] as const; + +/** Bumped whenever a tool is added, removed or renamed. + * + * Written into every managed MCP entry under `MCP_TOOLSET_KEY`. Hosts such as + * WorkBuddy cache a server's tool list under a fingerprint of its launch + * config, and our entry is otherwise byte-identical across upgrades, since it + * runs through the `current` symlink. Moving this value is what changes that + * fingerprint, so those hosts restart the server and drop the tools it no + * longer has. Deliberately not the build number: that would rewrite every + * host's config on every upgrade, for nothing. + */ +export const MCP_TOOLSET_VERSION = "3"; + +/** The env key the version is written under. */ +export const MCP_TOOLSET_KEY = "ONE_MEMORY_MCP_TOOLSET"; diff --git a/memhub/packages/host-bridge/src/opencode-installer.test.ts b/memhub/packages/host-bridge/src/opencode-installer.test.ts index 5d83d9a..931a37a 100644 --- a/memhub/packages/host-bridge/src/opencode-installer.test.ts +++ b/memhub/packages/host-bridge/src/opencode-installer.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { applyRecallInstall, + MCP_TOOLSET_VERSION, mcpConfigPath, opencodeLayout, planRecallInstall, @@ -46,6 +47,7 @@ describe("OpenCode recall installer", () => { ELECTRON_RUN_AS_NODE: "1", ONE_MEMORY_MANAGED_BY: "one-memory", ONE_MEMORY_BROKER_DISCOVERY: "/opt/one-memory/runtime.json", + ONE_MEMORY_MCP_TOOLSET: MCP_TOOLSET_VERSION, }, enabled: true, }); diff --git a/memhub/packages/host-bridge/src/recall-installer.ts b/memhub/packages/host-bridge/src/recall-installer.ts index e4627ec..1f0cd23 100644 --- a/memhub/packages/host-bridge/src/recall-installer.ts +++ b/memhub/packages/host-bridge/src/recall-installer.ts @@ -58,6 +58,7 @@ import { readDshServer, removeDshServer, } from "./dsh-patch.js"; +import { MCP_TOOLSET_KEY, MCP_TOOLSET_VERSION } from "./mcp-toolset.js"; import { RECALL_PRESENTATION_GUIDANCE, RECALL_PRESENTATION_GUIDANCE_ZH, @@ -88,8 +89,12 @@ export { type RecallLinkState, type RecallProbe, } from "./recall-probe.js"; - -export { planRecallRepoint, type RecallRepoint }; +export { + MCP_TOOLSET_KEY, + MCP_TOOLSET_VERSION, + planRecallRepoint, + type RecallRepoint, +}; /** Rewrite hosts whose installed command is not the one we would write now. * @@ -2844,6 +2849,9 @@ function managedServer(runtime: RecallRuntime): ManagedServer { ELECTRON_RUN_AS_NODE: "1", [DISCOVERY_KEY]: runtime.discoveryPath, [OWNER_KEY]: OWNER_VALUE, + // Moves the host's fingerprint of this entry when the tool set changes; + // see `MCP_TOOLSET_VERSION`. An entry without it reads as stale. + [MCP_TOOLSET_KEY]: MCP_TOOLSET_VERSION, ...(runtime.credentialFile === undefined ? {} : { ONE_MEMORY_HOST_CREDENTIAL_FILE: runtime.credentialFile }), diff --git a/memhub/packages/host-bridge/src/skill-drift.test.ts b/memhub/packages/host-bridge/src/skill-drift.test.ts index 7d18fb3..cd681a1 100644 --- a/memhub/packages/host-bridge/src/skill-drift.test.ts +++ b/memhub/packages/host-bridge/src/skill-drift.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { applyCursorInstall, planCursorInstall } from "./cursor-installer.js"; +import { MCP_TOOLSET_KEY, MCP_TOOLSET_VERSION } from "./mcp-toolset.js"; import { applyRecallInstall, applyRecallUninstall, @@ -15,6 +16,7 @@ import { planRecallUninstall, SKILL_MARKER, SKILL_VERSION, + verifyRecallInstall, } from "./recall-installer.js"; import { checkSkillDrift, @@ -387,6 +389,59 @@ describe("skill drift (R-33)", () => { }); }, 30_000); + it("marks stale an MCP entry written before the tool set was stamped, and sync stamps it", async () => { + // Our entry is otherwise identical across upgrades, so hosts that cache a + // tool list by config fingerprint keep the removed tools until it changes. + const cursorDir = await fixtureDir(); + const discovery = path.join(cursorDir, "broker.json"); + await writeFile(discovery, "{}\n"); + await applyCursorInstall( + await planCursorInstall(options(cursorDir, discovery)), + ); + const layout = cursorLayout(cursorDir); + const configPath = layout.configPath as string; + const config = JSON.parse(await readFile(configPath, "utf8")) as { + mcpServers: Record }>; + }; + expect(config.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]).toBe( + MCP_TOOLSET_VERSION, + ); + // What an earlier release wrote and journaled: the same entry, no stamp. + delete config.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]; + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`); + const journal = JSON.parse(await readFile(layout.journalPath, "utf8")); + journal.installed_server_hash = createHash("sha256") + .update(JSON.stringify(config.mcpServers["one-memory"])) + .digest("hex"); + await writeFile(layout.journalPath, `${JSON.stringify(journal)}\n`); + + // Still intact as installed, so the link is not reported broken... + await expect(verifyRecallInstall(layout)).resolves.toMatchObject({ + hostId: "cursor", + }); + // ...but stale against this release, and safe to sync without asking. + await expect( + checkSkillDrift(layout, runtime(discovery)), + ).resolves.toMatchObject({ + state: "stale", + detail: "the MCP entry predates the tool set this MemHub serves", + }); + const sync = await planSkillSync(layout, runtime(discovery)); + expect(sync?.requiresConfirmation).toBe(false); + if (sync === null) throw new Error("expected a stale entry sync"); + await applyRecallInstall(sync.plan); + + const synced = JSON.parse(await readFile(configPath, "utf8")) as { + mcpServers: Record }>; + }; + expect(synced.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]).toBe( + MCP_TOOLSET_VERSION, + ); + await expect( + checkSkillDrift(layout, runtime(discovery)), + ).resolves.toMatchObject({ state: "in_sync" }); + }, 30_000); + it("requires recall for dependent answers, not on every unrelated turn", async () => { const cursorDir = await fixtureDir(); const discovery = path.join(cursorDir, "broker.json"); diff --git a/memhub/packages/host-bridge/src/skill-drift.ts b/memhub/packages/host-bridge/src/skill-drift.ts index 9ea7758..9501d6c 100644 --- a/memhub/packages/host-bridge/src/skill-drift.ts +++ b/memhub/packages/host-bridge/src/skill-drift.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import path from "node:path"; import { captureFilePath, namesCapture } from "./capture-hooks.js"; import { PLUGIN_HOOKS } from "./codebuddy-plugin.js"; +import { MCP_TOOLSET_KEY, MCP_TOOLSET_VERSION } from "./mcp-toolset.js"; import { asInstalled, expectedPluginHooks, @@ -9,6 +10,7 @@ import { type RecallHostLayout, type RecallInstallPlan, type RecallRuntime, + readInstalledRecallCommand, renderSkillContent, SKILL_VERSION, } from "./recall-installer.js"; @@ -99,7 +101,23 @@ export async function checkSkillDrift( runtime: RecallRuntime, ): Promise { const skill = await checkSkillFileDrift(layout, runtime); - if (skill === null || skill.state === "modified") return skill; + if (skill === null) { + // A host with no skill mechanism still has an entry whose tool set can + // age. One whose skill is merely missing is left alone: syncing would put + // back a file somebody removed. + if (skillFilePath(layout) !== null) return null; + return (await serverToolsetDrift(layout)) === "stale" + ? { + hostId: layout.hostId, + state: "stale", + installedVersion: null, + currentVersion: SKILL_VERSION, + skillPath: null, + detail: TOOLSET_STALE_DETAIL, + } + : null; + } + if (skill.state === "modified") return skill; const hooks = await pluginHooksDrift(layout, runtime); if (hooks === "modified") { return { @@ -108,15 +126,19 @@ export async function checkSkillDrift( detail: "the plugin hook file was edited after install", }; } - if (hooks === "stale" && skill.state === "in_sync") { + if (skill.state === "stale") return skill; + if (hooks === "stale") { return { ...skill, state: "stale", detail: "plugin hooks are still ours, but have moved since install", }; } + if ((await serverToolsetDrift(layout)) === "stale") { + return { ...skill, state: "stale", detail: TOOLSET_STALE_DETAIL }; + } const capture = await captureHooksDrift(layout); - if (capture === "stale" && skill.state === "in_sync") { + if (capture === "stale") { return { ...skill, state: "stale", @@ -126,6 +148,29 @@ export async function checkSkillDrift( return skill; } +const TOOLSET_STALE_DETAIL = + "the MCP entry predates the tool set this MemHub serves"; + +/** Whether our MCP entry names the tool set this release serves. + * + * The entry is otherwise identical across upgrades, so a host that caches a + * server's tool list under a fingerprint of its config (WorkBuddy does) keeps + * listing tools that are gone until the entry itself changes. Only an entry + * marked as ours is read; a CLI-channel host has no entry at all. + */ +async function serverToolsetDrift( + layout: RecallHostLayout, +): Promise<"in_sync" | "stale" | null> { + if (layout.channel !== "mcp") return null; + // A config the host cannot parse either is not ours to judge here; the + // install and link checks report it. + const installed = await readInstalledRecallCommand(layout).catch(() => null); + if (installed === null) return null; + return installed.env[MCP_TOOLSET_KEY] === MCP_TOOLSET_VERSION + ? "in_sync" + : "stale"; +} + /** Detect duplicated command hooks left by earlier installs. * * `checkSkillDrift` historically compared only the Skill and plugin hooks; diff --git a/memhub/packages/runtime-control/src/host-permissions.ts b/memhub/packages/runtime-control/src/host-permissions.ts index 67d2a54..9f42f93 100644 --- a/memhub/packages/runtime-control/src/host-permissions.ts +++ b/memhub/packages/runtime-control/src/host-permissions.ts @@ -41,6 +41,7 @@ import { repairInstalledRecallCommands, verifyRecallInstall, } from "@one-memory/host-bridge/installer"; +import type { DiagnosticInput, DiagnosticSink } from "./diagnostic-log.js"; import { hasSyncHookArtifacts, loadSyncHookAssets, @@ -96,8 +97,22 @@ interface InstallationBinding { } const CREDENTIAL_ENV = "ONE_MEMORY_HOST_CREDENTIAL_FILE"; +/** The build whose host refresh last completed, beside the grants. */ +const HOST_REFRESH_FILE = "host-refresh.json"; const locks = new Map>(); +/** What one pass of `refreshInstalledHosts` did, by host id. */ +export interface HostRefreshReport { + /** `already_refreshed` when this build had already completed a pass. */ + state: "refreshed" | "already_refreshed"; + checked: number; + /** Hosts whose installed command or discovery path was moved. */ + repointed: string[]; + /** Hosts whose stale skill, hooks, plugin files or MCP entry were rewritten. */ + synced: string[]; + failed: string[]; +} + export const LOCAL_EXPERIENCE_UNAVAILABLE = "体验版暂不支持"; /** Trusted local Runtime control plane. Never exposed through MCP or the Core API. @@ -201,11 +216,20 @@ export class HostPermissions { * touching authorization. A skill the user edited is left for the detail page. */ async syncStaleInstalls(layouts: RecallHostLayout[]): Promise { + return (await this.syncStale(layouts)).synced; + } + + private async syncStale( + layouts: RecallHostLayout[], + ): Promise<{ synced: string[]; failed: string[] }> { const synced: string[] = []; + const failed: string[] = []; for (const layout of layouts) { try { await this.withHostLock(layout, async () => { - await this.requireLinked(layout); + // Not linked from this data directory is not ours to sync, and not + // a failure either. + if (!(await this.isLinked(layout))) return; const sync = await planSkillSync(layout, this.runtimeFor(layout)); if (sync === null || sync.requiresConfirmation) return; const preview = await previewRecallInstall(sync.plan); @@ -215,9 +239,128 @@ export class HostPermissions { }); } catch { // One host that cannot be synced must not stop the runtime starting. + failed.push(layout.hostId); } } - return synced; + return { synced, failed }; + } + + /** Refresh every host this directory installed, once per build. + * + * An upgrade changes the call rules, hooks and MCP entry we would write, but + * nothing rewrote the copies hosts already had, so they kept telling the + * model to call tools that were gone. This runs the same repoint and stale + * sync the detail page offers, without asking, for files that are still + * exactly ours; an edited skill stays for the user to confirm. + * + * The build it last completed for is recorded beside the grants, so later + * launches of the same build cost one file read. A run in which any host + * failed is not recorded, so the next launch tries again. With no build to + * name (a source checkout) it runs every time and records nothing. + */ + async refreshInstalledHosts( + layouts: RecallHostLayout[], + options: { build?: string; diagnostics?: DiagnosticSink } = {}, + ): Promise { + const log = (input: DiagnosticInput) => + options.diagnostics?.log(input).catch(() => undefined); + const build = options.build?.trim() || undefined; + if (build !== undefined && (await this.readRefreshedBuild()) === build) { + await log({ + level: "info", + component: "host.refresh", + event: "host_refresh.skipped", + outcome: "skipped", + details: { reason: "already_refreshed", count: layouts.length }, + }); + return { + state: "already_refreshed", + checked: layouts.length, + repointed: [], + synced: [], + failed: [], + }; + } + const started = Date.now(); + const repointed: string[] = []; + const failed: string[] = []; + for (const layout of layouts) { + try { + repointed.push(...(await this.repairInstalledCommands([layout]))); + } catch { + failed.push(layout.hostId); + } + } + const stale = await this.syncStale(layouts); + failed.push(...stale.failed.filter((hostId) => !failed.includes(hostId))); + for (const hostId of new Set([...repointed, ...stale.synced])) { + await log({ + level: "info", + component: "host.refresh", + event: "host_refresh.host_updated", + outcome: "succeeded", + details: { source_id: hostId }, + }); + } + for (const hostId of failed) { + await log({ + level: "warn", + component: "host.refresh", + event: "host_refresh.host_failed", + outcome: "failed", + error_code: "host_refresh_failed", + details: { source_id: hostId }, + }); + } + if (build !== undefined && failed.length === 0) { + await this.writeRefreshedBuild(build).catch(() => undefined); + } + await log({ + level: failed.length === 0 ? "info" : "warn", + component: "host.refresh", + event: "host_refresh.completed", + outcome: failed.length === 0 ? "succeeded" : "degraded", + duration_ms: Date.now() - started, + details: { + count: layouts.length, + updated_count: new Set([...repointed, ...stale.synced]).size, + error_count: failed.length, + }, + }); + return { + state: "refreshed", + checked: layouts.length, + repointed, + synced: stale.synced, + failed, + }; + } + + private async readRefreshedBuild(): Promise { + try { + return await storageGuard(async () => { + const file = path.join(this.directory, HOST_REFRESH_FILE); + if ((await optionalStat(file)) === null) return null; + const value: unknown = JSON.parse(await privateRead(file)); + return isObject(value) && + value.schemaVersion === 1 && + typeof value.build === "string" + ? value.build + : null; + }); + } catch { + // Unreadable reads as never refreshed: the refresh is idempotent. + return null; + } + } + + private async writeRefreshedBuild(build: string): Promise { + await storageGuard(() => + privateWrite( + path.join(this.directory, HOST_REFRESH_FILE), + `${JSON.stringify({ schemaVersion: 1, build })}\n`, + ), + ); } /** Same repair for one host. Does not revoke or reissue its authorization. */ @@ -942,6 +1085,16 @@ export class HostPermissions { throw new Error("host_permission_installation_incomplete"); } + /** `requireLinked` as a question, for sweeps that skip hosts not ours. */ + private async isLinked(layout: RecallHostLayout): Promise { + if (!supported(layout)) return false; + const current = await this.inspect(layout, false); + return ( + (current.state === "approved" || current.state === "install-only") && + current.reason === null + ); + } + /** Turning off a host with nothing of ours on it is an empty removal, not a * failure; one whose state cannot be settled is an empty removal carrying * what to look at, which uninstall refuses. Only what is ours for certain diff --git a/memhub/packages/runtime-control/src/index.ts b/memhub/packages/runtime-control/src/index.ts index 285875a..7f7e2e1 100644 --- a/memhub/packages/runtime-control/src/index.ts +++ b/memhub/packages/runtime-control/src/index.ts @@ -79,6 +79,7 @@ export { } from "./host-control-client.js"; export { HostPermissions, + type HostRefreshReport, LOCAL_EXPERIENCE_UNAVAILABLE, type RecallPermissionStatus, type RevokeRecallPermissionInput, diff --git a/memhub/packages/runtime-control/src/runtime-cli.test.ts b/memhub/packages/runtime-control/src/runtime-cli.test.ts index 175b111..8f724b6 100644 --- a/memhub/packages/runtime-control/src/runtime-cli.test.ts +++ b/memhub/packages/runtime-control/src/runtime-cli.test.ts @@ -226,6 +226,7 @@ describe("host runtime attestation", () => { "repairInstalledCommand", "repairInstalledCommands", "syncStaleInstalls", + "refreshInstalledHosts", "previewInstall", "install", "setPermission", diff --git a/memhub/packages/runtime-control/src/runtime-cli.ts b/memhub/packages/runtime-control/src/runtime-cli.ts index ad3d207..70e906b 100644 --- a/memhub/packages/runtime-control/src/runtime-cli.ts +++ b/memhub/packages/runtime-control/src/runtime-cli.ts @@ -600,6 +600,7 @@ export function guardedPermissions( "previewSyncHook", "applySyncHook", "syncStaleInstalls", + "refreshInstalledHosts", ]); return new Proxy(owner, { get(target, property, receiver) { @@ -739,10 +740,17 @@ export async function createRuntimeService( ).filter((host) => host.hostId !== "openclaw"); await verifyHostRuntime().then( async () => { - await permissions.repairInstalledCommands( + // Every Runtime start after an upgrade, the App's or `memhub + // upgrade`'s, comes through here first; once per build after that. + await permissions.refreshInstalledHosts( hosts.map((host) => host.layout), + { + build: + config.buildIdentity?.buildNumber ?? + config.buildIdentity?.version, + diagnostics: hostDiagnostics, + }, ); - await permissions.syncStaleInstalls(hosts.map((host) => host.layout)); }, () => undefined, );