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:
Fusion
2026-04-15 05:27:07 -07:00
committed by gsxdsm
parent e22c929264
commit b316b2e552
7 changed files with 645 additions and 13 deletions

View File

@@ -328,6 +328,29 @@ describe("bin", () => {
paused: true,
interactive: true,
host: "127.0.0.1",
daemon: false,
});
});
it("routes serve command with --daemon flag", async () => {
await runBin(["serve", "--daemon"]);
expect(runServe).toHaveBeenCalledWith(4040, {
paused: false,
interactive: false,
host: undefined,
daemon: true,
});
});
it("routes serve command with --daemon flag combined with other options", async () => {
await runBin(["serve", "--port", "6060", "--daemon", "--host", "0.0.0.0"]);
expect(runServe).toHaveBeenCalledWith(6060, {
paused: false,
interactive: false,
host: "0.0.0.0",
daemon: true,
});
});
@@ -403,7 +426,7 @@ describe("bin", () => {
const help = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(help).toContain("fn project list | ls");
expect(help).toContain("fn node list | ls");
expect(help).toContain("fn serve [--port <port>] [--host <host>] [--paused]");
expect(help).toContain("fn serve [--port <port>] [--host <host>] [--paused] [--daemon]");
expect(help).toContain("fn task comments <id>");
expect(help).toContain("--project, -P <name>");
});

View File

@@ -68,8 +68,9 @@ Usage:
fn dashboard --paused Start with automation paused
fn dashboard --dev Start web UI only (no AI engine)
fn dashboard --interactive Start with interactive port selection
fn serve [--port <port>] [--host <host>] [--paused]
fn serve [--port <port>] [--host <host>] [--paused] [--daemon]
Start Fusion as a headless node (API + engine, no UI)
Use --daemon to enable bearer token authentication
fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--token-only]
Start Fusion daemon (API + engine, auth required)
fn desktop Launch the Fusion desktop app (Electron)
@@ -327,7 +328,8 @@ async function main() {
const interactive = args.includes("--interactive");
const hostIdx = args.indexOf("--host");
const host = hostIdx !== -1 && hostIdx + 1 < args.length ? args[hostIdx + 1] : undefined;
await runServe(port, { paused, interactive, host });
const daemon = args.includes("--daemon");
await runServe(port, { paused, interactive, host, daemon });
break;
}

View File

@@ -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,
});
});
});

View File

@@ -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");
});
});

View File

@@ -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();

View File

@@ -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;

View File

@@ -0,0 +1,284 @@
/**
* Integration tests for auth middleware with createServer.
* Tests the full end-to-end authentication flow: valid token accepted,
* no/invalid token rejected, health endpoint exempt, backward compatibility.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { EventEmitter } from "node:events";
import type { Task, TaskStore } from "@fusion/core";
import { request } from "../test-request.js";
import { createServer } from "../server.js";
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockClose = vi.fn().mockResolvedValue(undefined);
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
CentralCore: vi.fn().mockImplementation(() => ({
init: mockInit,
close: mockClose,
getLocalNode: vi.fn().mockResolvedValue({
id: "node_local",
name: "local",
type: "local",
status: "online",
maxConcurrent: 2,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
listNodes: vi.fn().mockResolvedValue([
{
id: "node_local",
name: "local",
type: "local",
status: "online",
maxConcurrent: 2,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
]),
updateNode: vi.fn().mockResolvedValue(undefined),
})),
};
});
class MockStore extends EventEmitter {
getRootDir(): string {
return "/tmp/fn-auth-test";
}
getFusionDir(): string {
return "/tmp/fn-auth-test/.fusion";
}
getDatabase() {
return {
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({
run: vi.fn().mockReturnValue({ changes: 0 }),
get: vi.fn(),
all: vi.fn().mockReturnValue([]),
}),
};
}
getMissionStore() {
return {
listMissions: vi.fn().mockResolvedValue([]),
createMission: vi.fn(),
getMission: vi.fn(),
updateMission: vi.fn(),
deleteMission: vi.fn(),
listTemplates: vi.fn().mockResolvedValue([]),
createTemplate: vi.fn(),
getTemplate: vi.fn(),
updateTemplate: vi.fn(),
deleteTemplate: vi.fn(),
instantiateMission: vi.fn(),
};
}
async listTasks(): Promise<Task[]> {
return [];
}
}
describe("Auth middleware integration with createServer", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("with daemonToken option", () => {
it("accepts valid bearer token", async () => {
const store = new MockStore();
const app = createServer(store as unknown as TaskStore, {
daemon: { token: "fn_test1234567890abcdef" },
});
const response = await request(
app,
"GET",
"/api/tasks",
undefined,
{ Authorization: "Bearer fn_test1234567890abcdef" }
);
expect(response.status).toBe(200);
});
it("rejects request without Authorization header", async () => {
const store = new MockStore();
const app = createServer(store as unknown as TaskStore, {
daemon: { token: "fn_test1234567890abcdef" },
});
const response = await request(app, "GET", "/api/tasks");
expect(response.status).toBe(401);
expect(response.body).toMatchObject({
error: "Unauthorized",
message: "Valid bearer token required",
});
});
it("rejects request with invalid bearer token", async () => {
const store = new MockStore();
const app = createServer(store as unknown as TaskStore, {
daemon: { token: "fn_test1234567890abcdef" },
});
const response = await request(
app,
"GET",
"/api/tasks",
undefined,
{ Authorization: "Bearer fn_wrong_token" }
);
expect(response.status).toBe(401);
expect(response.body).toMatchObject({
error: "Unauthorized",
message: "Valid bearer token required",
});
});
it("exempts /api/health from authentication", async () => {
const store = new MockStore();
const app = createServer(store as unknown as TaskStore, {
daemon: { token: "fn_test1234567890abcdef" },
});
const response = await request(app, "GET", "/api/health");
expect(response.status).toBe(200);
});
it("exempts /api/health/ with subpath from authentication", async () => {
const store = new MockStore();
const app = createServer(store as unknown as TaskStore, {
daemon: { token: "fn_test1234567890abcdef" },
});
// /api/health returns 200 (health check endpoint)
// The middleware correctly exempts paths starting with /api/health/
// but the specific path /api/health/detailed may not exist (404 vs 401)
// We test that the request is NOT rejected with 401 (auth not enforced)
const response = await request(app, "GET", "/api/health/detailed");
// The auth middleware is bypassed, so we get 404 (route not found) not 401 (unauthorized)
expect(response.status).not.toBe(401);
});
it("accepts valid token with Bearer prefix variation", async () => {
const store = new MockStore();
const app = createServer(store as unknown as TaskStore, {
daemon: { token: "fn_test1234567890abcdef" },
});
// Test that the middleware correctly parses Bearer prefix
const response = await request(
app,
"GET",
"/api/tasks",
undefined,
{ Authorization: "Bearer fn_test1234567890abcdef" }
);
expect(response.status).toBe(200);
});
it("rejects request with wrong Bearer prefix", async () => {
const store = new MockStore();
const app = createServer(store as unknown as TaskStore, {
daemon: { token: "fn_test1234567890abcdef" },
});
const response = await request(
app,
"GET",
"/api/tasks",
undefined,
{ Authorization: "Basic fn_test1234567890abcdef" }
);
expect(response.status).toBe(401);
});
it("rejects request with empty Bearer token", async () => {
const store = new MockStore();
const app = createServer(store as unknown as TaskStore, {
daemon: { token: "fn_test1234567890abcdef" },
});
const response = await request(
app,
"GET",
"/api/tasks",
undefined,
{ Authorization: "Bearer " }
);
expect(response.status).toBe(401);
});
});
describe("without daemonToken option (backward compatibility)", () => {
it("allows unauthenticated access when no daemonToken is set", async () => {
const store = new MockStore();
const app = createServer(store as unknown as TaskStore);
const response = await request(app, "GET", "/api/tasks");
expect(response.status).toBe(200);
});
it("allows access to /api/health without daemonToken", async () => {
const store = new MockStore();
const app = createServer(store as unknown as TaskStore);
const response = await request(app, "GET", "/api/health");
expect(response.status).toBe(200);
});
});
describe("with FUSION_DAEMON_TOKEN environment variable", () => {
const originalEnv = process.env.FUSION_DAEMON_TOKEN;
afterEach(() => {
if (originalEnv !== undefined) {
process.env.FUSION_DAEMON_TOKEN = originalEnv;
} else {
delete process.env.FUSION_DAEMON_TOKEN;
}
});
it("activates auth when FUSION_DAEMON_TOKEN env var is set", async () => {
process.env.FUSION_DAEMON_TOKEN = "fn_envtoken1234567890";
const store = new MockStore();
const app = createServer(store as unknown as TaskStore);
// Should reject without token
const noAuthResponse = await request(app, "GET", "/api/tasks");
expect(noAuthResponse.status).toBe(401);
// Should accept with correct token
const authResponse = await request(
app,
"GET",
"/api/tasks",
undefined,
{ Authorization: "Bearer fn_envtoken1234567890" }
);
expect(authResponse.status).toBe(200);
// Should exempt health endpoint
const healthResponse = await request(app, "GET", "/api/health");
expect(healthResponse.status).toBe(200);
});
});
});