feat(FN-3707): implement mesh auth snapshot replication across core, engine

The merge delivers two major features: mesh auth snapshot synchronization across nodes (FN-3707 steps 2/4/5, including core types, central-core plumbing, peer-exchange updates, and settings/node sync route helpers) and a bundled roadmap plugin with chat UX improvements (FN-3162, FN-3756, FN-3771, FN

Fusion-Task-Id: FN-3707
This commit is contained in:
Fusion
2026-05-08 18:15:15 -07:00
committed by gsxdsm
parent 7913ea1f61
commit 05329fb059
14 changed files with 226 additions and 76 deletions

View File

@@ -31,6 +31,8 @@ const mockGetSettingsSyncState = vi.fn();
const mockUpdateSettingsSyncState = vi.fn();
const mockApplyRemoteSettings = vi.fn();
const mockGetSettingsForSync = vi.fn();
const mockGetAuthMaterialSnapshot = vi.fn();
const mockApplyAuthMaterialSnapshot = vi.fn();
const mockChatStoreInit = vi.fn().mockResolvedValue(undefined);
const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined);
const mockAgentStoreGetAgent = vi.fn().mockResolvedValue(null);
@@ -47,6 +49,8 @@ vi.mock("@fusion/core", () => {
updateSettingsSyncState = mockUpdateSettingsSyncState;
applyRemoteSettings = mockApplyRemoteSettings;
getSettingsForSync = mockGetSettingsForSync;
getAuthMaterialSnapshot = mockGetAuthMaterialSnapshot;
applyAuthMaterialSnapshot = mockApplyAuthMaterialSnapshot;
},
ChatStore: class MockChatStore {
init = mockChatStoreInit;
@@ -173,6 +177,17 @@ describe("Node settings sync routes", () => {
mockUpdateSettingsSyncState.mockResolvedValue({});
mockApplyRemoteSettings.mockResolvedValue({ success: true, globalCount: 1, projectCount: 1, authCount: 0 });
mockGetSettingsForSync.mockResolvedValue({});
mockGetAuthMaterialSnapshot.mockReturnValue({
version: 1,
exportedAt: "2026-04-14T10:00:00.000Z",
checksum: "auth-checksum",
payload: { providerAuth: { anthropic: { type: "api_key", key: "sk-ant-test" } } },
});
mockApplyAuthMaterialSnapshot.mockReturnValue({
success: true,
authCount: 1,
providerAuth: { anthropic: { type: "api_key", key: "sk-ant-received" } },
});
mockAuthStorageSet.mockResolvedValue(undefined);
mockAuthStorageGetOAuthProviders.mockReturnValue([]);
@@ -636,11 +651,25 @@ describe("Node settings sync routes", () => {
it("emits structured redacted diagnostics for pull-mode auth sync", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockApplyAuthMaterialSnapshot.mockReturnValueOnce({
success: true,
authCount: 1,
providerAuth: {
google: { type: "api_key", key: "sk-pull-secret-123" },
},
});
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({
providers: {
google: { type: "api_key", key: "sk-pull-secret-123" },
authMaterial: {
version: 1,
exportedAt: "2026-04-14T10:00:00.000Z",
checksum: "auth-checksum",
payload: {
providerAuth: {
google: { type: "api_key", key: "sk-pull-secret-123" },
},
},
},
sourceNodeId: "node-other",
timestamp: "2026-04-14T10:00:00.000Z",
@@ -781,8 +810,11 @@ describe("Node settings sync routes", () => {
"POST",
"/api/settings/auth-receive",
JSON.stringify({
providers: {
anthropic: { type: "api_key", key: "sk-ant-received" },
authMaterial: {
version: 1,
exportedAt: "2026-04-14T10:00:00.000Z",
checksum: "auth-checksum",
payload: { providerAuth: { anthropic: { type: "api_key", key: "sk-ant-received" } } },
},
sourceNodeId: "node-remote-001",
timestamp: "2026-04-14T10:00:00.000Z",
@@ -801,7 +833,12 @@ describe("Node settings sync routes", () => {
"POST",
"/api/settings/auth-receive",
JSON.stringify({
providers: { anthropic: { type: "api_key", key: "sk-ant" } },
authMaterial: {
version: 1,
exportedAt: "2026-04-14T10:00:00.000Z",
checksum: "auth-checksum",
payload: { providerAuth: { anthropic: { type: "api_key", key: "sk-ant" } } },
},
sourceNodeId: "node-remote-001",
timestamp: "2026-04-14T10:00:00.000Z",
}),
@@ -819,7 +856,7 @@ describe("Node settings sync routes", () => {
app,
"POST",
"/api/settings/auth-receive",
JSON.stringify({ providers: "not-an-object" }),
JSON.stringify({ authMaterial: "not-an-object" }),
{ "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` },
);
@@ -835,7 +872,12 @@ describe("Node settings sync routes", () => {
"POST",
"/api/settings/auth-receive",
JSON.stringify({
providers: { anthropic: { type: "api_key", key: "sk-ant-secret" } },
authMaterial: {
version: 1,
exportedAt: "2026-04-14T10:00:00.000Z",
checksum: "auth-checksum",
payload: { providerAuth: { anthropic: { type: "api_key", key: "sk-ant-secret" } } },
},
sourceNodeId: "node-remote-001",
timestamp: "2026-04-14T10:00:00.000Z",
}),
@@ -890,11 +932,11 @@ describe("Node settings sync routes", () => {
);
expect(res.status).toBe(200);
expect(res.body.providers).toBeDefined();
expect(res.body.authMaterial).toBeDefined();
expect(res.body.sourceNodeId).toBe("node-local-001");
// The actual providers depend on what's in ~/.pi/agent/auth.json
// Just verify we got a providers object
expect(typeof res.body.providers).toBe("object");
// Just verify we got a providerAuth snapshot payload
expect(typeof res.body.authMaterial.payload.providerAuth).toBe("object");
});
it("returns 401 when auth header is missing", async () => {

View File

@@ -458,16 +458,29 @@ export const registerMeshRoutes: ApiRouteRegistrar = (ctx) => {
}
});
if (sharedState.authMaterial) {
emitRemoteRouteDiagnostic({
route: "mesh-sync",
message: "Ignoring sharedState.authMaterial; use dedicated auth sync routes (/api/nodes/:id/auth/*)",
nodeId: senderNodeId,
upstreamPath: "/api/mesh/sync",
operationStage: "apply-shared-state-auth-material",
level: "info",
});
}
await applyDomain("auth-material", async () => {
if (!sharedState.authMaterial) return;
validateSnapshotEnvelope(sharedState.authMaterial);
const applied = central.applyAuthMaterialSnapshot(sharedState.authMaterial as Parameters<typeof central.applyAuthMaterialSnapshot>[0]);
const { AuthStorage } = await import("@mariozechner/pi-coding-agent");
const { getFusionAuthPath } = await import("../auth-paths.js");
const authStorage = AuthStorage.create(getFusionAuthPath());
for (const [providerId, credential] of Object.entries(applied.providerAuth)) {
if (credential.type === "api_key" && credential.key) {
authStorage.set(providerId, { type: "api_key", key: credential.key });
continue;
}
if (credential.type === "oauth" && credential.accessToken && credential.refreshToken && typeof credential.expires === "number") {
authStorage.set(providerId, {
type: "oauth",
access: credential.accessToken,
refresh: credential.refreshToken,
expires: credential.expires,
...(credential.accountId ? { accountId: credential.accountId } : {}),
});
}
}
});
// Intentionally do not close this per-request AgentStore wrapper.
// AgentStore uses a process-wide DB cache by rootDir; closing here would
@@ -518,6 +531,11 @@ export const registerMeshRoutes: ApiRouteRegistrar = (ctx) => {
const localGlobal = await store.getGlobalSettingsStore().getSettings();
return central.getProjectSettingsSnapshot(localGlobal);
});
await collectSnapshot("authMaterial", async () => {
const authPathsModule = await import("./register-settings-sync-helpers.js");
const allProviders = await authPathsModule.readStoredAuthProvidersFromDisk();
return central.getAuthMaterialSnapshot(authPathsModule.toProviderAuthEntries(allProviders));
});
await central.close();

View File

@@ -1,5 +1,5 @@
import { readFile as fsReadFile } from "node:fs/promises";
import type { NodeConfig } from "@fusion/core";
import type { AuthMaterialSnapshot, NodeConfig, ProviderAuthEntry } from "@fusion/core";
import { ApiError } from "../api-error.js";
import { getAuthFileCandidates, type StoredAuthProvider } from "../auth-paths.js";
@@ -19,6 +19,37 @@ export async function readStoredAuthProvidersFromDisk(): Promise<Record<string,
return merged;
}
export function toProviderAuthEntries(
providers: Record<string, StoredAuthProvider>,
): Record<string, ProviderAuthEntry> {
const providerAuth: Record<string, ProviderAuthEntry> = {};
for (const [providerId, credential] of Object.entries(providers)) {
if (credential?.type === "api_key" && credential.key) {
providerAuth[providerId] = { type: "api_key", key: credential.key };
continue;
}
if (
credential?.type === "oauth"
&& typeof credential.access === "string"
&& typeof credential.refresh === "string"
&& typeof credential.expires === "number"
) {
providerAuth[providerId] = {
type: "oauth",
accessToken: credential.access,
refreshToken: credential.refresh,
expires: credential.expires,
accountId: credential.accountId,
};
}
}
return providerAuth;
}
export function getProviderNamesFromAuthSnapshot(snapshot: AuthMaterialSnapshot): string[] {
return Object.keys(snapshot.payload.providerAuth ?? {});
}
/**
* Validate node and make an authenticated fetch call to a remote node.
* Returns parsed JSON on success, throws ApiError on failure.

View File

@@ -1,6 +1,6 @@
import { ApiError, badRequest } from "../api-error.js";
import { getFusionAuthPath } from "../auth-paths.js";
import { readStoredAuthProvidersFromDisk } from "./register-settings-sync-helpers.js";
import { readStoredAuthProvidersFromDisk, toProviderAuthEntries } from "./register-settings-sync-helpers.js";
import type { ApiRouteRegistrar } from "./types.js";
export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
@@ -110,12 +110,12 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
throw new ApiError(401, "Invalid apiKey");
}
const { providers, sourceNodeId, timestamp } = req.body || {};
const { authMaterial, sourceNodeId, timestamp } = req.body || {};
// Validate required fields
if (!providers || typeof providers !== "object") {
if (!authMaterial || typeof authMaterial !== "object") {
await central.close();
throw badRequest("Missing required field: providers");
throw badRequest("Missing required field: authMaterial");
}
if (!sourceNodeId) {
await central.close();
@@ -130,14 +130,23 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
const { AuthStorage } = await import("@mariozechner/pi-coding-agent");
const authStorage = AuthStorage.create(getFusionAuthPath());
const applyResult = central.applyAuthMaterialSnapshot(authMaterial);
const receivedProviders: string[] = [];
for (const [providerId, credential] of Object.entries(providers)) {
if (typeof credential === "object" && credential !== null) {
const cred = credential as { type: string; key?: string; access?: string; refresh?: string; expires?: number; accountId?: string };
if (cred.type === "api_key" && cred.key) {
authStorage.set(providerId, { type: "api_key", key: cred.key });
receivedProviders.push(providerId);
}
for (const [providerId, credential] of Object.entries(applyResult.providerAuth)) {
if (credential.type === "api_key" && credential.key) {
authStorage.set(providerId, { type: "api_key", key: credential.key });
receivedProviders.push(providerId);
continue;
}
if (credential.type === "oauth" && credential.accessToken && credential.refreshToken && typeof credential.expires === "number") {
authStorage.set(providerId, {
type: "oauth",
access: credential.accessToken,
refresh: credential.refreshToken,
expires: credential.expires,
...(credential.accountId ? { accountId: credential.accountId } : {}),
});
receivedProviders.push(providerId);
}
}
@@ -194,19 +203,12 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
const localPeerInfo = await central.getLocalPeerInfo();
const allProviders = await readStoredAuthProvidersFromDisk();
// Filter to only API-key-based providers (skip OAuth)
const apiKeyProviders: Record<string, { type: string; key: string }> = {};
for (const [providerId, cred] of Object.entries(allProviders)) {
if (cred.type === "api_key" && cred.key) {
apiKeyProviders[providerId] = { type: "api_key", key: cred.key };
}
}
const authMaterial = central.getAuthMaterialSnapshot(toProviderAuthEntries(allProviders));
await central.close();
res.json({
providers: apiKeyProviders,
authMaterial,
sourceNodeId: localPeerInfo.nodeId,
timestamp: new Date().toISOString(),
});

View File

@@ -2,7 +2,7 @@ import type { ProjectSettings } from "@fusion/core";
import { basename } from "node:path";
import { ApiError, badRequest, notFound } from "../api-error.js";
import { getFusionAuthPath } from "../auth-paths.js";
import { fetchFromRemoteNode, readStoredAuthProvidersFromDisk } from "./register-settings-sync-helpers.js";
import { fetchFromRemoteNode, readStoredAuthProvidersFromDisk, toProviderAuthEntries } from "./register-settings-sync-helpers.js";
import type { ApiRouteRegistrar } from "./types.js";
export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
@@ -338,26 +338,14 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
const authStorage = AuthStorage.create(getFusionAuthPath());
if (direction === "push") {
// Get OAuth provider IDs to exclude
const oauthProviders = authStorage.getOAuthProviders();
const oauthIds = new Set(oauthProviders.map((p) => p.id));
const allProviders = await readStoredAuthProvidersFromDisk();
// Filter to only API-key-based providers (skip OAuth)
const apiKeyProviders: Record<string, { type: string; key: string }> = {};
for (const [providerId, cred] of Object.entries(allProviders)) {
if (oauthIds.has(providerId)) continue;
if (cred.type === "api_key" && cred.key) {
apiKeyProviders[providerId] = { type: "api_key", key: cred.key };
}
}
const authMaterial = central.getAuthMaterialSnapshot(toProviderAuthEntries(allProviders));
// Send to remote
await fetchFromRemoteNode(node, "/api/settings/auth-receive", {
method: "POST",
body: {
providers: apiKeyProviders,
authMaterial,
sourceNodeId: localPeerInfo.nodeId,
timestamp,
},
@@ -370,7 +358,7 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
await central.close();
const providerNames = Object.keys(apiKeyProviders);
const providerNames = Object.keys(authMaterial.payload.providerAuth ?? {});
emitAuthSyncAuditLog({
operation: "sync",
direction: "push",
@@ -384,17 +372,30 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
} else {
// Pull: fetch remote auth and apply locally
const remoteAuth = await fetchFromRemoteNode(node, "/api/settings/auth-export") as {
providers: Record<string, { type: string; key: string }>;
authMaterial: import("@fusion/core").AuthMaterialSnapshot;
sourceNodeId: string;
timestamp: string;
};
const applied = central.applyAuthMaterialSnapshot(remoteAuth.authMaterial);
// Write received credentials to local AuthStorage
const syncedProviders: string[] = [];
for (const [providerId, credential] of Object.entries(remoteAuth.providers || {})) {
for (const [providerId, credential] of Object.entries(applied.providerAuth)) {
if (credential.type === "api_key" && credential.key) {
authStorage.set(providerId, { type: "api_key", key: credential.key });
syncedProviders.push(providerId);
continue;
}
if (credential.type === "oauth" && credential.accessToken && credential.refreshToken && typeof credential.expires === "number") {
authStorage.set(providerId, {
type: "oauth",
access: credential.accessToken,
refresh: credential.refreshToken,
expires: credential.expires,
...(credential.accountId ? { accountId: credential.accountId } : {}),
});
syncedProviders.push(providerId);
}
}