feat(FN-1832): add daemon mode and auth-aware node connect
- Add --daemon flag to fn serve command for headless node operation - Add daemon-aware messaging to fn node connect (shows auth status) - Add CLI integration tests for daemon mode (serve.test.ts) - Add auth middleware integration tests (auth-middleware-integration.test.ts) - Fix auth middleware test for /api/health subpath - Update bin.test.ts with daemon mode test coverage
This commit is contained in:
@@ -669,3 +669,136 @@ describe("node commands", () => {
|
||||
await expect(runNodeList()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("daemon-aware node connect", () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
throw new Error("process.exit");
|
||||
}) as never);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListNodes.mockResolvedValue([]);
|
||||
mockRegisterNode.mockResolvedValue(makeNode());
|
||||
mockGetNode.mockResolvedValue(undefined);
|
||||
mockGetNodeByName.mockResolvedValue(undefined);
|
||||
mockUnregisterNode.mockResolvedValue(undefined);
|
||||
mockCheckNodeHealth.mockResolvedValue("online");
|
||||
mockListProjects.mockResolvedValue([]);
|
||||
mockQuestion.mockResolvedValue("y");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows '(authenticated)' when apiKey provided and health check succeeds", async () => {
|
||||
mockRegisterNode.mockResolvedValue(
|
||||
makeNode({
|
||||
id: "node_remote",
|
||||
name: "daemon-node",
|
||||
type: "remote",
|
||||
url: "https://daemon.example.com",
|
||||
}),
|
||||
);
|
||||
mockCheckNodeHealth.mockResolvedValue("online");
|
||||
|
||||
await runNodeConnect("daemon-node", {
|
||||
url: "https://daemon.example.com",
|
||||
apiKey: "fn_daemontoken123456",
|
||||
});
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("(authenticated)");
|
||||
expect(output).toContain("daemon-node");
|
||||
});
|
||||
|
||||
it("shows standard success message when no apiKey provided", async () => {
|
||||
mockRegisterNode.mockResolvedValue(
|
||||
makeNode({
|
||||
id: "node_remote",
|
||||
name: "regular-node",
|
||||
type: "remote",
|
||||
url: "https://regular.example.com",
|
||||
}),
|
||||
);
|
||||
mockCheckNodeHealth.mockResolvedValue("online");
|
||||
|
||||
await runNodeConnect("regular-node", {
|
||||
url: "https://regular.example.com",
|
||||
});
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("Node connected successfully");
|
||||
expect(output).not.toContain("(authenticated)");
|
||||
});
|
||||
|
||||
it("shows auth failure hint when apiKey provided but node is offline", async () => {
|
||||
mockRegisterNode.mockResolvedValue(
|
||||
makeNode({
|
||||
id: "node_remote",
|
||||
name: "offline-daemon",
|
||||
type: "remote",
|
||||
url: "https://offline.example.com",
|
||||
}),
|
||||
);
|
||||
mockCheckNodeHealth.mockResolvedValue("offline");
|
||||
|
||||
await runNodeConnect("offline-daemon", {
|
||||
url: "https://offline.example.com",
|
||||
apiKey: "fn_wrongtoken123456",
|
||||
});
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("API key may be incorrect");
|
||||
expect(output).toContain("offline");
|
||||
});
|
||||
|
||||
it("shows auth failure hint when apiKey provided but health check returns error", async () => {
|
||||
mockRegisterNode.mockResolvedValue(
|
||||
makeNode({
|
||||
id: "node_remote",
|
||||
name: "error-daemon",
|
||||
type: "remote",
|
||||
url: "https://error.example.com",
|
||||
}),
|
||||
);
|
||||
mockCheckNodeHealth.mockResolvedValue("error");
|
||||
|
||||
await runNodeConnect("error-daemon", {
|
||||
url: "https://error.example.com",
|
||||
apiKey: "fn_badtoken123456",
|
||||
});
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("API key may be incorrect");
|
||||
expect(output).toContain("error");
|
||||
});
|
||||
|
||||
it("registers node with apiKey when connecting to daemon", async () => {
|
||||
mockRegisterNode.mockResolvedValue(
|
||||
makeNode({
|
||||
id: "node_remote",
|
||||
name: "auth-node",
|
||||
type: "remote",
|
||||
url: "https://auth.example.com",
|
||||
}),
|
||||
);
|
||||
mockCheckNodeHealth.mockResolvedValue("online");
|
||||
|
||||
await runNodeConnect("auth-node", {
|
||||
url: "https://auth.example.com",
|
||||
apiKey: "fn_secret1234567890",
|
||||
maxConcurrent: 3,
|
||||
});
|
||||
|
||||
expect(mockRegisterNode).toHaveBeenCalledWith({
|
||||
name: "auth-node",
|
||||
type: "remote",
|
||||
url: "https://auth.example.com",
|
||||
apiKey: "fn_secret1234567890",
|
||||
maxConcurrent: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -459,6 +459,13 @@ vi.mock("@fusion/core", () => ({
|
||||
syncInsightExtractionAutomation: mocks.syncInsightExtractionAutomationMock,
|
||||
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
|
||||
processAndAuditInsightExtraction: mocks.processAndAuditInsightExtractionMock,
|
||||
DaemonTokenManager: vi.fn().mockImplementation(() => ({
|
||||
getToken: vi.fn().mockResolvedValue(null),
|
||||
generateToken: vi.fn().mockResolvedValue("fn_generated1234567890"),
|
||||
storeToken: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
GlobalSettingsStore: vi.fn().mockImplementation(() => ({})),
|
||||
resolveGlobalDir: vi.fn().mockReturnValue("/mock/global"),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/dashboard", () => ({
|
||||
@@ -1238,3 +1245,129 @@ describe("runServe — Peer exchange and discovery", () => {
|
||||
expect(nodeCentral.updateNode).toHaveBeenCalledWith("node-local", { status: "offline" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runServe --daemon flag", () => {
|
||||
const originalCwd = process.cwd;
|
||||
const originalOn = process.on;
|
||||
const originalExit = process.exit;
|
||||
const originalEnv = process.env.FUSION_DAEMON_TOKEN;
|
||||
|
||||
let signalHandlers: Record<"SIGINT" | "SIGTERM", Array<() => void>>;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let cwdSpy: ReturnType<typeof vi.spyOn>;
|
||||
let processOnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
async function triggerSignal(signal: "SIGINT" | "SIGTERM") {
|
||||
const handlers = signalHandlers[signal];
|
||||
expect(handlers.length).toBeGreaterThan(0);
|
||||
handlers[handlers.length - 1]();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.reset();
|
||||
|
||||
signalHandlers = { SIGINT: [], SIGTERM: [] };
|
||||
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
|
||||
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
|
||||
if (event === "SIGINT" || event === "SIGTERM") {
|
||||
signalHandlers[event].push(listener);
|
||||
}
|
||||
return process;
|
||||
}) as typeof process.on);
|
||||
process.exit = vi.fn() as never;
|
||||
|
||||
// Clear env var before each test
|
||||
delete process.env.FUSION_DAEMON_TOKEN;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
cwdSpy.mockRestore();
|
||||
processOnSpy.mockRestore();
|
||||
process.cwd = originalCwd;
|
||||
process.on = originalOn;
|
||||
process.exit = originalExit;
|
||||
|
||||
// Restore env var
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.FUSION_DAEMON_TOKEN = originalEnv;
|
||||
} else {
|
||||
delete process.env.FUSION_DAEMON_TOKEN;
|
||||
}
|
||||
});
|
||||
|
||||
it("passes daemonToken to createServer when daemon: true", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
|
||||
await runServe(4040, { daemon: true });
|
||||
|
||||
expect(createServer).toHaveBeenCalledTimes(1);
|
||||
const serverOpts = createServer.mock.calls[0][1];
|
||||
expect(serverOpts.daemon).toBeDefined();
|
||||
expect(serverOpts.daemon?.token).toBeDefined();
|
||||
expect(typeof serverOpts.daemon?.token).toBe("string");
|
||||
expect(serverOpts.daemon?.token).toMatch(/^fn_/);
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("shows '(daemon mode)' in startup banner when daemon: true", async () => {
|
||||
await runServe(4040, { daemon: true });
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("(daemon mode)");
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("shows 'fn node connect' hint in startup banner when daemon: true", async () => {
|
||||
await runServe(4040, { daemon: true });
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("fn node connect");
|
||||
expect(output).toContain("--api-key");
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("resolves token from FUSION_DAEMON_TOKEN env var", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
process.env.FUSION_DAEMON_TOKEN = "fn_envtest1234567890";
|
||||
|
||||
await runServe(4040, { daemon: true });
|
||||
|
||||
expect(createServer).toHaveBeenCalledTimes(1);
|
||||
const serverOpts = createServer.mock.calls[0][1];
|
||||
expect(serverOpts.daemon?.token).toBe("fn_envtest1234567890");
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("does not pass daemon to createServer when daemon: false", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
|
||||
await runServe(4040, { daemon: false });
|
||||
|
||||
expect(createServer).toHaveBeenCalledTimes(1);
|
||||
const serverOpts = createServer.mock.calls[0][1];
|
||||
expect(serverOpts.daemon).toBeUndefined();
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("does not pass daemon to createServer when daemon option is omitted", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(createServer).toHaveBeenCalledTimes(1);
|
||||
const serverOpts = createServer.mock.calls[0][1];
|
||||
expect(serverOpts.daemon).toBeUndefined();
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -288,7 +288,13 @@ export async function runNodeConnect(
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(" ✓ Node connected successfully");
|
||||
if (options.apiKey && status === "online") {
|
||||
console.log(` ✓ Node '${node.name}' connected (authenticated)`);
|
||||
} else if (options.apiKey && (status === "offline" || status === "error")) {
|
||||
console.log(` ⚠ Node is offline — the API key may be incorrect or the node may be unreachable`);
|
||||
} else {
|
||||
console.log(" ✓ Node connected successfully");
|
||||
}
|
||||
console.log();
|
||||
} finally {
|
||||
await central.close();
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
getTaskMergeBlocker,
|
||||
INSIGHT_EXTRACTION_SCHEDULE_NAME,
|
||||
processAndAuditInsightExtraction,
|
||||
DaemonTokenManager,
|
||||
GlobalSettingsStore,
|
||||
resolveGlobalDir,
|
||||
} from "@fusion/core";
|
||||
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
|
||||
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath } from "@fusion/dashboard";
|
||||
@@ -186,7 +189,7 @@ function ensureProcessDiagnostics(): void {
|
||||
|
||||
export async function runServe(
|
||||
port: number,
|
||||
opts: { interactive?: boolean; paused?: boolean; host?: string } = {},
|
||||
opts: { interactive?: boolean; paused?: boolean; host?: string; daemon?: boolean } = {},
|
||||
) {
|
||||
serveStartTime = Date.now();
|
||||
ensureProcessDiagnostics();
|
||||
@@ -510,6 +513,34 @@ export async function runServe(
|
||||
|
||||
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry);
|
||||
|
||||
// ── Daemon token resolution ─────────────────────────────────────────────
|
||||
//
|
||||
// When --daemon flag is set, resolve the daemon token using the same
|
||||
// priority as fn daemon: env var > stored token > generate new token.
|
||||
//
|
||||
let daemonToken: string | undefined;
|
||||
if (opts.daemon) {
|
||||
// 1. Check environment variable first
|
||||
daemonToken = process.env.FUSION_DAEMON_TOKEN;
|
||||
|
||||
// 2. Check stored token in global settings
|
||||
if (!daemonToken) {
|
||||
const globalDir = resolveGlobalDir();
|
||||
const settingsStore = new GlobalSettingsStore(globalDir);
|
||||
const tokenManager = new DaemonTokenManager(settingsStore);
|
||||
daemonToken = await tokenManager.getToken();
|
||||
}
|
||||
|
||||
// 3. Generate and store a new token if none exists
|
||||
if (!daemonToken) {
|
||||
const globalDir = resolveGlobalDir();
|
||||
const settingsStore = new GlobalSettingsStore(globalDir);
|
||||
const tokenManager = new DaemonTokenManager(settingsStore);
|
||||
daemonToken = await tokenManager.generateToken();
|
||||
await tokenManager.storeToken(daemonToken);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Skills adapter for skills discovery and execution toggling ─────────────
|
||||
//
|
||||
// Create the skills adapter using the same DefaultPackageManager instance
|
||||
@@ -547,6 +578,7 @@ export async function runServe(
|
||||
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
|
||||
headless: true,
|
||||
skillsAdapter,
|
||||
daemon: daemonToken ? { token: daemonToken } : undefined,
|
||||
});
|
||||
|
||||
const server = app.listen(selectedPort, selectedHost);
|
||||
@@ -610,15 +642,34 @@ export async function runServe(
|
||||
console.warn(`[serve] Failed to set local node online: ${message}`);
|
||||
}
|
||||
|
||||
// Import maskApiKey helper for token display
|
||||
const { maskApiKey } = await import("./node.js");
|
||||
|
||||
console.log();
|
||||
console.log(` Fusion Node`);
|
||||
console.log(` ────────────────────────`);
|
||||
console.log(` → http://${selectedHost}:${actualPort}`);
|
||||
console.log();
|
||||
console.log(` Health: GET /api/health`);
|
||||
console.log(` API: /api/*`);
|
||||
console.log(` AI engine: ✓ active`);
|
||||
console.log(` Press Ctrl+C to stop`);
|
||||
if (daemonToken) {
|
||||
console.log(` Fusion Node (daemon mode)`);
|
||||
console.log(` ────────────────────────`);
|
||||
console.log(` → http://${selectedHost}:${actualPort}`);
|
||||
console.log();
|
||||
console.log(` Token: fn_${maskApiKey(daemonToken)}`);
|
||||
console.log();
|
||||
console.log(` Connect from another machine:`);
|
||||
console.log(` fn node connect <name> --url http://<host>:<port> --api-key ${daemonToken}`);
|
||||
console.log();
|
||||
console.log(` Health: GET /api/health`);
|
||||
console.log(` API: /api/* (bearer token required)`);
|
||||
console.log(` AI engine: ✓ active`);
|
||||
console.log(` Press Ctrl+C to stop`);
|
||||
} else {
|
||||
console.log(` Fusion Node`);
|
||||
console.log(` ────────────────────────`);
|
||||
console.log(` → http://${selectedHost}:${actualPort}`);
|
||||
console.log();
|
||||
console.log(` Health: GET /api/health`);
|
||||
console.log(` API: /api/*`);
|
||||
console.log(` AI engine: ✓ active`);
|
||||
console.log(` Press Ctrl+C to stop`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
Reference in New Issue
Block a user