fix(memhub): refresh harness skills and MCP entry after an upgrade
After an upgrade, hosts kept the call-rule skill and MCP entry an older release wrote, so models were still told to call MemHub_ask and the other removed tools, and WorkBuddy kept listing them from its tool-list cache. - Stamp every managed MCP entry with ONE_MEMORY_MCP_TOOLSET, a tool contract version kept beside the tool list (mcp-toolset.ts). WorkBuddy fingerprints the stdio transport, env included, so a new stamp makes it restart the server and drop the cached list. An entry without the current stamp reads as stale in skill drift; verify still passes it. - HostPermissions.refreshInstalledHosts runs the existing repoint and stale-sync planners for every host this data dir installed, leaves user-edited skills alone, emits allowlisted host_refresh.* diagnostics (host ids and counts only), and records the build it completed in host-permissions/host-refresh.json so it runs once per build. - The Runtime calls it as it starts (covering App and `memhub upgrade`), and Desktop calls it after an embedded Runtime is up. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1086f4d582
commit
30c0b1d5ff
16 files changed
+559
-12
No files matched your search
@@ -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(),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<typeof fixture>>) {
|
||||
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<typeof workbuddyLayout>) {
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -1369,6 +1369,24 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
});
|
||||
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" &&
|
||||
|
||||
@@ -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": "<absolute path to the discovery file>",
|
||||
"ONE_MEMORY_MANAGED_BY": "one-memory"
|
||||
"ONE_MEMORY_MANAGED_BY": "one-memory",
|
||||
"ONE_MEMORY_MCP_TOOLSET": "3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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<string, { env: Record<string, string> }>;
|
||||
};
|
||||
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<string, { env: Record<string, string> }>;
|
||||
};
|
||||
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");
|
||||
|
||||
@@ -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<SkillDrift | null> {
|
||||
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;
|
||||
|
||||
@@ -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<string, Promise<void>>();
|
||||
|
||||
/** 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<string[]> {
|
||||
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<HostRefreshReport> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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
|
||||
|
||||
@@ -79,6 +79,7 @@ export {
|
||||
} from "./host-control-client.js";
|
||||
export {
|
||||
HostPermissions,
|
||||
type HostRefreshReport,
|
||||
LOCAL_EXPERIENCE_UNAVAILABLE,
|
||||
type RecallPermissionStatus,
|
||||
type RevokeRecallPermissionInput,
|
||||
|
||||
@@ -226,6 +226,7 @@ describe("host runtime attestation", () => {
|
||||
"repairInstalledCommand",
|
||||
"repairInstalledCommands",
|
||||
"syncStaleInstalls",
|
||||
"refreshInstalledHosts",
|
||||
"previewInstall",
|
||||
"install",
|
||||
"setPermission",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user