feat(FN-1830): merge fusion/fn-1830

This commit is contained in:
gsxdsm
2026-04-15 03:30:56 -07:00
parent 4f4b06605d
commit 49239b5dfa
5 changed files with 443 additions and 1 deletions

View File

@@ -0,0 +1,293 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { GlobalSettingsStore } from "./global-settings.js";
import {
DaemonTokenManager,
DAEMON_TOKEN_PREFIX,
DAEMON_TOKEN_HEX_LENGTH,
isDaemonTokenFormat,
} from "./daemon-token.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "fn-daemon-token-test-"));
}
describe("isDaemonTokenFormat", () => {
it("returns true for valid format", () => {
expect(isDaemonTokenFormat("fn_a1b2c3d4e5f6789012345678abcdef01")).toBe(true);
});
it("returns true for all lowercase hex", () => {
expect(isDaemonTokenFormat("fn_0123456789abcdef0123456789abcdef")).toBe(true);
});
it("returns false for missing prefix", () => {
expect(isDaemonTokenFormat("a1b2c3d4e5f6789012345678abcdef01")).toBe(false);
});
it("returns false for wrong prefix", () => {
expect(isDaemonTokenFormat("fn__a1b2c3d4e5f6789012345678abcdef01")).toBe(false);
});
it("returns false for wrong length (too short)", () => {
expect(isDaemonTokenFormat("fn_a1b2c3d4e5f6789012345678abcdef0")).toBe(false);
});
it("returns false for wrong length (too long)", () => {
expect(isDaemonTokenFormat("fn_a1b2c3d4e5f6789012345678abcdef012")).toBe(false);
});
it("returns false for uppercase hex", () => {
expect(isDaemonTokenFormat("fn_A1B2C3D4E5F6789012345678ABCDEF01")).toBe(false);
});
it("returns false for mixed case hex", () => {
expect(isDaemonTokenFormat("fn_A1b2C3d4E5f6789012345678AbCdEf01")).toBe(false);
});
it("returns false for empty string", () => {
expect(isDaemonTokenFormat("")).toBe(false);
});
it("returns false for special characters", () => {
expect(isDaemonTokenFormat("fn_a1b2c3d4e5f6789012345678abcdef0!")).toBe(false);
});
it("returns false for prefix only", () => {
expect(isDaemonTokenFormat("fn_")).toBe(false);
});
});
describe("DaemonTokenManager", () => {
let dir: string;
let store: GlobalSettingsStore;
let manager: DaemonTokenManager;
beforeEach(() => {
dir = makeTmpDir();
store = new GlobalSettingsStore(dir);
manager = new DaemonTokenManager(store);
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
describe("generateToken()", () => {
it("generates a token with correct format", async () => {
const token = await manager.generateToken();
expect(token).toMatch(/^fn_[0-9a-f]{32}$/);
expect(token.startsWith(DAEMON_TOKEN_PREFIX)).toBe(true);
expect(token.length).toBe(DAEMON_TOKEN_PREFIX.length + DAEMON_TOKEN_HEX_LENGTH);
});
it("stores the token in settings", async () => {
const token = await manager.generateToken();
const settings = await store.getSettings();
expect(settings.daemonToken).toBe(token);
});
it("returns the generated token", async () => {
const token = await manager.generateToken();
expect(typeof token).toBe("string");
expect(token.length).toBeGreaterThan(0);
});
it("throws if token already exists", async () => {
await manager.generateToken();
await expect(manager.generateToken()).rejects.toThrow(
"Daemon token already exists. Use rotateToken() to replace it.",
);
});
it("generates unique tokens on each call", async () => {
// First, rotate to get an existing token
const firstToken = await manager.rotateToken();
// Rotate again to get a second token
const secondToken = await manager.rotateToken();
expect(firstToken).not.toBe(secondToken);
});
});
describe("getToken()", () => {
it("returns undefined when no token", async () => {
const token = await manager.getToken();
expect(token).toBeUndefined();
});
it("returns stored token after generation", async () => {
const generated = await manager.generateToken();
const retrieved = await manager.getToken();
expect(retrieved).toBe(generated);
});
it("returns stored token after rotation", async () => {
await manager.rotateToken();
const retrieved = await manager.getToken();
expect(retrieved).toMatch(/^fn_[0-9a-f]{32}$/);
});
});
describe("validateToken()", () => {
it("returns true for valid token", async () => {
const token = await manager.generateToken();
const isValid = await manager.validateToken(token);
expect(isValid).toBe(true);
});
it("returns false for wrong token", async () => {
await manager.generateToken();
const isValid = await manager.validateToken(
"fn_00000000000000000000000000000001",
);
expect(isValid).toBe(false);
});
it("returns false when no token stored", async () => {
const isValid = await manager.validateToken(
"fn_a1b2c3d4e5f6789012345678abcdef01",
);
expect(isValid).toBe(false);
});
it("returns false for empty string", async () => {
await manager.generateToken();
const isValid = await manager.validateToken("");
expect(isValid).toBe(false);
});
it("returns false for wrong length token", async () => {
await manager.generateToken();
const isValid = await manager.validateToken(
"fn_a1b2c3d4e5f6789012345678abcdef0", // one char short
);
expect(isValid).toBe(false);
});
it("handles timing-safe comparison correctly", async () => {
const token = await manager.generateToken();
// Valid token should return true
expect(await manager.validateToken(token)).toBe(true);
// Invalid token should return false
expect(await manager.validateToken("fn_00000000000000000000000000000001")).toBe(false);
});
});
describe("rotateToken()", () => {
it("generates new token replacing old", async () => {
const oldToken = await manager.generateToken();
const newToken = await manager.rotateToken();
expect(newToken).not.toBe(oldToken);
expect(newToken).toMatch(/^fn_[0-9a-f]{32}$/);
});
it("returns different token each call", async () => {
const tokens = new Set<string>();
for (let i = 0; i < 5; i++) {
tokens.add(await manager.rotateToken());
}
// All tokens should be unique
expect(tokens.size).toBe(5);
});
it("works when no existing token", async () => {
const token = await manager.rotateToken();
expect(token).toMatch(/^fn_[0-9a-f]{32}$/);
expect(await manager.getToken()).toBe(token);
});
it("stores new token after rotation", async () => {
await manager.generateToken();
await manager.rotateToken();
const stored = await manager.getToken();
expect(stored).toMatch(/^fn_[0-9a-f]{32}$/);
});
});
describe("integration: full lifecycle", () => {
it("generate → validate → rotate → validate new → old token fails", async () => {
// Generate a token
const token = await manager.generateToken();
// Validate the original token
expect(await manager.validateToken(token)).toBe(true);
// Rotate to get a new token
const newToken = await manager.rotateToken();
// Old token should no longer be valid
expect(await manager.validateToken(token)).toBe(false);
// New token should be valid
expect(await manager.validateToken(newToken)).toBe(true);
// New token should be different from old
expect(newToken).not.toBe(token);
});
});
describe("token format specifics", () => {
it("generated token has correct prefix", async () => {
const token = await manager.generateToken();
expect(token.startsWith(DAEMON_TOKEN_PREFIX)).toBe(true);
});
it("generated token has exactly 32 lowercase hex chars", async () => {
const token = await manager.generateToken();
const hexPart = token.slice(DAEMON_TOKEN_PREFIX.length);
expect(hexPart).toMatch(/^[0-9a-f]{32}$/);
});
it("generated token has correct total length", async () => {
const token = await manager.generateToken();
expect(token.length).toBe(DAEMON_TOKEN_PREFIX.length + DAEMON_TOKEN_HEX_LENGTH);
});
});
describe("DAEMON_TOKEN_PREFIX constant", () => {
it("is fn_", () => {
expect(DAEMON_TOKEN_PREFIX).toBe("fn_");
});
});
describe("DAEMON_TOKEN_HEX_LENGTH constant", () => {
it("is 32", () => {
expect(DAEMON_TOKEN_HEX_LENGTH).toBe(32);
});
});
});

View File

@@ -0,0 +1,123 @@
/**
* Daemon token management for fn daemon mode authentication.
*
* Daemon tokens are stored in global settings and used to authenticate
* CLI clients connecting to the daemon server.
*/
import { randomBytes, timingSafeEqual } from "node:crypto";
import { GlobalSettingsStore } from "./global-settings.js";
/** Prefix for daemon authentication tokens. */
export const DAEMON_TOKEN_PREFIX = "fn_";
/** Number of hex characters in the token body (16 bytes = 32 hex chars). */
export const DAEMON_TOKEN_HEX_LENGTH = 32;
/** Regular expression for validating daemon token format. */
const DAEMON_TOKEN_REGEX = /^fn_[0-9a-f]{32}$/;
/**
* Validate that a string matches the daemon token format (fn_<32 hex chars>).
*
* @param value - The string to validate
* @returns true if the string matches the expected format
*/
export function isDaemonTokenFormat(value: string): boolean {
return DAEMON_TOKEN_REGEX.test(value);
}
/**
* Manages daemon authentication token lifecycle: generation, storage, validation, and rotation.
*
* Tokens are stored in global settings alongside user preferences. This class
* provides a clean API for CLI and server components to manage daemon tokens
* without directly coupling to GlobalSettingsStore.
*/
export class DaemonTokenManager {
constructor(private readonly settingsStore: GlobalSettingsStore) {}
/**
* Generate a new daemon token and store it.
*
* @returns The generated token string (e.g., "fn_a1b2c3...")
* @throws Error if a token already exists. Use rotateToken() to replace.
*/
async generateToken(): Promise<string> {
const existing = await this.settingsStore.getSettings();
if (existing.daemonToken !== undefined) {
throw new Error("Daemon token already exists. Use rotateToken() to replace it.");
}
const token = this.generateTokenValue();
await this.settingsStore.updateSettings({ daemonToken: token });
return token;
}
/**
* Retrieve the currently stored daemon token, if any.
*
* @returns The stored token or undefined if no token has been generated.
*/
async getToken(): Promise<string | undefined> {
const settings = await this.settingsStore.getSettings();
return settings.daemonToken;
}
/**
* Validate that a provided token matches the stored token.
*
* Uses constant-time comparison to prevent timing attacks.
*
* @param token - The token to validate
* @returns true if the token matches the stored token, false otherwise
*/
async validateToken(token: string): Promise<boolean> {
const stored = await this.getToken();
// No stored token means validation fails
if (stored === undefined) {
return false;
}
// Fast path: check length first to avoid unnecessary crypto calls
if (token.length !== stored.length) {
return false;
}
// Constant-time comparison to prevent timing attacks
try {
const tokenBuffer = Buffer.from(token, "utf8");
const storedBuffer = Buffer.from(stored, "utf8");
return timingSafeEqual(tokenBuffer, storedBuffer);
} catch {
// Buffer lengths don't match (shouldn't happen if length check passes)
// or encoding issues - treat as mismatch
return false;
}
}
/**
* Generate a new token, replacing any existing token.
*
* This method is idempotent: it works whether or not a token currently exists.
*
* @returns The newly generated token string
*/
async rotateToken(): Promise<string> {
const token = this.generateTokenValue();
await this.settingsStore.updateSettings({ daemonToken: token });
return token;
}
/**
* Generate a random token value without storing it.
*
* @internal
* @returns A new token string in the format "fn_<32 hex chars>"
*/
private generateTokenValue(): string {
const hexChars = randomBytes(16).toString("hex");
return `${DAEMON_TOKEN_PREFIX}${hexChars}`;
}
}

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, CheckoutConflictError } from "./types.js"; export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, CheckoutConflictError } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js"; export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js"; export { AGENT_VALID_TRANSITIONS } from "./types.js";
export { export {
BUILTIN_AGENT_PROMPTS, BUILTIN_AGENT_PROMPTS,
@@ -46,6 +46,7 @@ export { Database, createDatabase, toJson, toJsonNullable, fromJson } from "./db
export type { Statement } from "./db.js"; export type { Statement } from "./db.js";
export { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "./db-migrate.js"; export { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "./db-migrate.js";
export { GlobalSettingsStore, resolveGlobalDir } from "./global-settings.js"; export { GlobalSettingsStore, resolveGlobalDir } from "./global-settings.js";
export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./daemon-token.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js"; export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
export { getTaskMergeBlocker, getTaskCompletionBlocker, isTaskReadyForMerge } from "./task-merge.js"; export { getTaskMergeBlocker, getTaskCompletionBlocker, isTaskReadyForMerge } from "./task-merge.js";
export { export {

View File

@@ -38,6 +38,10 @@ export const DEFAULT_GLOBAL_SETTINGS = {
validatorGlobalModelId: undefined, validatorGlobalModelId: undefined,
titleSummarizerGlobalProvider: undefined, titleSummarizerGlobalProvider: undefined,
titleSummarizerGlobalModelId: undefined, titleSummarizerGlobalModelId: undefined,
// Daemon mode settings
daemonToken: undefined,
daemonPort: 4040,
daemonHost: "0.0.0.0",
} satisfies CompleteSettings<GlobalSettings>; } satisfies CompleteSettings<GlobalSettings>;
/** Default values for project-level settings. */ /** Default values for project-level settings. */

View File

@@ -825,6 +825,20 @@ export interface TaskCreateInput {
/** Settings scope discriminator for UI and validation. */ /** Settings scope discriminator for UI and validation. */
export type SettingsScope = "global" | "project"; export type SettingsScope = "global" | "project";
/**
* Settings for daemon mode authentication token and server configuration.
* Stored in global settings alongside user preferences.
*/
export interface DaemonTokenSettings {
/** The daemon authentication token (format: fn_<32 hex chars>).
* Used for authenticating CLI clients to the daemon server. */
daemonToken?: string;
/** Port for daemon mode server binding. Default: 4040. */
daemonPort?: number;
/** Host for daemon mode server binding. Default: "0.0.0.0" (all interfaces). */
daemonHost?: string;
}
/** /**
* Global (user-level) settings stored in `~/.pi/fusion/settings.json`. * Global (user-level) settings stored in `~/.pi/fusion/settings.json`.
* *
@@ -931,6 +945,13 @@ export interface GlobalSettings {
/** Global baseline AI model ID for title summarization. /** Global baseline AI model ID for title summarization.
* Must be set together with `titleSummarizerGlobalProvider`. */ * Must be set together with `titleSummarizerGlobalProvider`. */
titleSummarizerGlobalModelId?: string; titleSummarizerGlobalModelId?: string;
/** The daemon authentication token (format: fn_<32 hex chars>).
* Used for authenticating CLI clients to the daemon server. */
daemonToken?: string;
/** Port for daemon mode server binding. Default: 4040. */
daemonPort?: number;
/** Host for daemon mode server binding. Default: "0.0.0.0" (all interfaces). */
daemonHost?: string;
} }
/** /**