feat(FN-2406): persist dashboard auth token and harden TUI layout
- Persist dashboard auth token in global settings and add daemon-token utilities for reuse - Update dashboard CLI command auth precedence and token handling behavior - Harden dashboard TUI log viewport budgeting to avoid footer overlap under constrained heights - Expand CLI and TUI test coverage for token persistence, auth precedence, and environment mocking - Refresh README/CLI/getting-started docs and add changesets for token persistence and TUI fix
This commit is contained in:
5
.changeset/persist-dashboard-token.md
Normal file
5
.changeset/persist-dashboard-token.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Persist `fn dashboard` bearer tokens in the existing global settings store (`~/.fusion/settings.json`) on first authenticated run, then reuse them on subsequent starts. Explicit overrides (`--token`, `FUSION_DASHBOARD_TOKEN`, `FUSION_DAEMON_TOKEN`) and `--no-auth` precedence remain intact.
|
||||||
@@ -263,10 +263,13 @@ pnpm dev dashboard
|
|||||||
|
|
||||||
Then click the `Open:` URL printed in the terminal. It embeds a bearer token
|
Then click the `Open:` URL printed in the terminal. It embeds a bearer token
|
||||||
(`http://localhost:4040/?token=fn_...`) that the browser captures to
|
(`http://localhost:4040/?token=fn_...`) that the browser captures to
|
||||||
`localStorage` on first visit and reuses automatically thereafter. See
|
`localStorage` on first visit and reuses automatically thereafter. On the
|
||||||
|
server side, Fusion now persists the dashboard/daemon token in
|
||||||
|
`~/.fusion/settings.json` on first authenticated run and reuses it on later
|
||||||
|
starts unless you override it (`--token`, `FUSION_DASHBOARD_TOKEN`,
|
||||||
|
`FUSION_DAEMON_TOKEN`) or disable auth with `--no-auth`. See
|
||||||
[CLI reference → fn dashboard → Authentication](./docs/cli-reference.md#fn-dashboard)
|
[CLI reference → fn dashboard → Authentication](./docs/cli-reference.md#fn-dashboard)
|
||||||
for how to pin a stable token via `FUSION_DASHBOARD_TOKEN` or opt out with
|
for full precedence and reset/revocation options.
|
||||||
`--no-auth`.
|
|
||||||
|
|
||||||
### First-run setup
|
### First-run setup
|
||||||
|
|
||||||
|
|||||||
@@ -134,8 +134,14 @@ plain console output to maintain compatibility with automated workflows.
|
|||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
Unless `--no-auth` is passed, the dashboard API (including the terminal
|
Unless `--no-auth` is passed, the dashboard API (including the terminal
|
||||||
WebSocket) is protected by a bearer token. On startup, Fusion prints both the
|
WebSocket) is protected by a bearer token. On first authenticated startup,
|
||||||
raw token and a click-to-open URL that embeds `?token=<token>`:
|
Fusion resolves a token via the daemon-token manager and persists it in the
|
||||||
|
existing global settings file (`~/.fusion/settings.json`, owner-only when
|
||||||
|
supported). Later dashboard startups reuse the same stored token unless you
|
||||||
|
explicitly override it.
|
||||||
|
|
||||||
|
On startup, Fusion prints both the resolved token and a click-to-open URL that
|
||||||
|
embeds `?token=<token>`:
|
||||||
|
|
||||||
```
|
```
|
||||||
fn dashboard
|
fn dashboard
|
||||||
@@ -154,21 +160,24 @@ closing and reopening the tab) reuse the stored token.
|
|||||||
|
|
||||||
Precedence when resolving the token:
|
Precedence when resolving the token:
|
||||||
|
|
||||||
1. `--token <token>` flag
|
1. `--no-auth` (disables auth middleware entirely)
|
||||||
2. `FUSION_DASHBOARD_TOKEN` environment variable
|
2. `--token <token>` flag
|
||||||
3. `FUSION_DAEMON_TOKEN` environment variable (back-compat with `fn daemon`)
|
3. `FUSION_DASHBOARD_TOKEN` environment variable
|
||||||
4. Random `fn_<32 hex>` generated per run
|
4. `FUSION_DAEMON_TOKEN` environment variable (back-compat with `fn daemon`)
|
||||||
|
5. Stored token in `~/.fusion/settings.json`
|
||||||
|
6. New generated token persisted to `~/.fusion/settings.json` (first authenticated run)
|
||||||
|
|
||||||
To reuse a stable token across runs, export one of the env vars:
|
To override defaults without changing stored settings, export one of the env vars:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export FUSION_DASHBOARD_TOKEN=fn_my_stable_token
|
export FUSION_DASHBOARD_TOKEN=fn_my_override_token
|
||||||
fn dashboard
|
fn dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
If you ever need to revoke access, either restart `fn dashboard` (which
|
To revoke/reset access, choose the behavior you want:
|
||||||
rotates the auto-generated token) or clear the `fn.authToken` entry from
|
- **Temporary override:** set `--token` / env var for the current run.
|
||||||
each client's `localStorage`.
|
- **Persistent reset:** clear `daemonToken` from `~/.fusion/settings.json` (or rotate it via `fn daemon --token-only`/token rotation workflow), then restart dashboard.
|
||||||
|
- **Client logout:** clear `fn.authToken` in browser localStorage so clients must re-authenticate with the current server token.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -169,8 +169,8 @@ Open: http://localhost:4040/?token=fn_8f3a...
|
|||||||
Click the **Open** link. Your browser captures the token into `localStorage`,
|
Click the **Open** link. Your browser captures the token into `localStorage`,
|
||||||
strips it from the visible URL, and reuses it automatically on later loads.
|
strips it from the visible URL, and reuses it automatically on later loads.
|
||||||
See [CLI reference → fn dashboard → Authentication](./cli-reference.md#fn-dashboard)
|
See [CLI reference → fn dashboard → Authentication](./cli-reference.md#fn-dashboard)
|
||||||
for details, including how to set a stable token via `FUSION_DASHBOARD_TOKEN`
|
for details, including token precedence (CLI/env overrides over the persisted
|
||||||
or disable auth with `--no-auth` for strictly-local setups.
|
`~/.fusion` token) and how to disable auth with `--no-auth` for strictly-local setups.
|
||||||
|
|
||||||
Other launch modes:
|
Other launch modes:
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,25 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
// Use vi.hoisted to define mocks that need to be referenced in vi.mock
|
// Use vi.hoisted to define mocks that need to be referenced in vi.mock
|
||||||
const { centralInstances } = vi.hoisted(() => {
|
const {
|
||||||
|
centralInstances,
|
||||||
|
mockResolveGlobalDir,
|
||||||
|
mockGlobalSettingsGetSettings,
|
||||||
|
mockGlobalSettingsUpdateSettings,
|
||||||
|
mockDaemonTokenGetOrCreate,
|
||||||
|
} = vi.hoisted(() => {
|
||||||
|
delete process.env.FUSION_DASHBOARD_TOKEN;
|
||||||
|
delete process.env.FUSION_DAEMON_TOKEN;
|
||||||
|
delete process.env.FUSION_BEARER_TOKEN;
|
||||||
|
|
||||||
const centralInstances: any[] = [];
|
const centralInstances: any[] = [];
|
||||||
return { centralInstances };
|
return {
|
||||||
|
centralInstances,
|
||||||
|
mockResolveGlobalDir: vi.fn().mockReturnValue("/tmp/test-global"),
|
||||||
|
mockGlobalSettingsGetSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
mockGlobalSettingsUpdateSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
mockDaemonTokenGetOrCreate: vi.fn().mockResolvedValue("fn_test_dashboard_token"),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Multi-project test fixtures ─────────────────────────────────────────
|
// ── Multi-project test fixtures ─────────────────────────────────────────
|
||||||
@@ -151,6 +167,16 @@ vi.mock("@fusion/core", () => ({
|
|||||||
getLoadedPlugins: vi.fn().mockReturnValue([]),
|
getLoadedPlugins: vi.fn().mockReturnValue([]),
|
||||||
})),
|
})),
|
||||||
getEnabledPiExtensionPaths: vi.fn(() => []),
|
getEnabledPiExtensionPaths: vi.fn(() => []),
|
||||||
|
resolveGlobalDir: mockResolveGlobalDir,
|
||||||
|
GlobalSettingsStore: vi.fn().mockImplementation(() => ({
|
||||||
|
getSettings: mockGlobalSettingsGetSettings,
|
||||||
|
updateSettings: mockGlobalSettingsUpdateSettings,
|
||||||
|
})),
|
||||||
|
DaemonTokenManager: vi.fn().mockImplementation(() => ({
|
||||||
|
getOrCreateToken: mockDaemonTokenGetOrCreate,
|
||||||
|
getToken: vi.fn().mockResolvedValue(undefined),
|
||||||
|
generateToken: vi.fn().mockResolvedValue("fn_test_dashboard_token"),
|
||||||
|
})),
|
||||||
getTaskMergeBlocker: vi.fn().mockReturnValue(undefined),
|
getTaskMergeBlocker: vi.fn().mockReturnValue(undefined),
|
||||||
syncInsightExtractionAutomation: mockSyncInsightExtraction,
|
syncInsightExtractionAutomation: mockSyncInsightExtraction,
|
||||||
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
|
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
|
||||||
@@ -411,11 +437,114 @@ function setupProjectByPath(
|
|||||||
|
|
||||||
// ── Tests ───────────────────────────────────────────────────────────
|
// ── Tests ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.FUSION_DASHBOARD_TOKEN;
|
||||||
|
delete process.env.FUSION_DAEMON_TOKEN;
|
||||||
|
delete process.env.FUSION_BEARER_TOKEN;
|
||||||
|
mockResolveGlobalDir.mockReset();
|
||||||
|
mockResolveGlobalDir.mockReturnValue("/tmp/test-global");
|
||||||
|
mockGlobalSettingsGetSettings.mockReset();
|
||||||
|
mockGlobalSettingsGetSettings.mockResolvedValue({});
|
||||||
|
mockGlobalSettingsUpdateSettings.mockReset();
|
||||||
|
mockGlobalSettingsUpdateSettings.mockResolvedValue({});
|
||||||
|
mockDaemonTokenGetOrCreate.mockReset();
|
||||||
|
mockDaemonTokenGetOrCreate.mockResolvedValue("fn_test_dashboard_token");
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
disposeTrackedDashboards();
|
disposeTrackedDashboards();
|
||||||
resetMultiProjectState();
|
resetMultiProjectState();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("runDashboard — dashboard auth token persistence + precedence", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockDiscoverAndLoadExtensions.mockResolvedValue({
|
||||||
|
runtime: { pendingProviderRegistrations: [] },
|
||||||
|
errors: [],
|
||||||
|
});
|
||||||
|
const { TaskStore, DaemonTokenManager } = await import("@fusion/core");
|
||||||
|
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
|
||||||
|
(DaemonTokenManager as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||||
|
getOrCreateToken: mockDaemonTokenGetOrCreate,
|
||||||
|
getToken: vi.fn().mockResolvedValue(undefined),
|
||||||
|
generateToken: vi.fn().mockResolvedValue("fn_test_dashboard_token"),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates exactly once on first authenticated run and reuses token on subsequent runs", async () => {
|
||||||
|
const { DaemonTokenManager } = await import("@fusion/core");
|
||||||
|
const { createServer } = await import("@fusion/dashboard");
|
||||||
|
|
||||||
|
let storedToken: string | undefined;
|
||||||
|
let generationCount = 0;
|
||||||
|
|
||||||
|
(DaemonTokenManager as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||||
|
getOrCreateToken: vi.fn().mockImplementation(async () => {
|
||||||
|
if (!storedToken) {
|
||||||
|
generationCount += 1;
|
||||||
|
storedToken = "fn_persisted_dashboard_token";
|
||||||
|
}
|
||||||
|
return storedToken;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await runDashboard(0, { open: false, dev: true });
|
||||||
|
const firstOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls.at(-1)![1];
|
||||||
|
|
||||||
|
await runDashboard(0, { open: false, dev: true });
|
||||||
|
const secondOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls.at(-1)![1];
|
||||||
|
|
||||||
|
expect(generationCount).toBe(1);
|
||||||
|
expect(firstOpts.daemon).toEqual({ token: "fn_persisted_dashboard_token" });
|
||||||
|
expect(secondOpts.daemon).toEqual({ token: "fn_persisted_dashboard_token" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses --token over env vars and persisted token", async () => {
|
||||||
|
const { createServer } = await import("@fusion/dashboard");
|
||||||
|
process.env.FUSION_DASHBOARD_TOKEN = "fn_env_dashboard_token";
|
||||||
|
process.env.FUSION_DAEMON_TOKEN = "fn_env_daemon_token";
|
||||||
|
|
||||||
|
await runDashboard(0, { open: false, dev: true, token: "fn_cli_token" });
|
||||||
|
|
||||||
|
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls.at(-1)![1];
|
||||||
|
expect(serverOpts.daemon).toEqual({ token: "fn_cli_token" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses FUSION_DASHBOARD_TOKEN before FUSION_DAEMON_TOKEN", async () => {
|
||||||
|
const { createServer } = await import("@fusion/dashboard");
|
||||||
|
process.env.FUSION_DASHBOARD_TOKEN = "fn_env_dashboard_token";
|
||||||
|
process.env.FUSION_DAEMON_TOKEN = "fn_env_daemon_token";
|
||||||
|
|
||||||
|
await runDashboard(0, { open: false, dev: true });
|
||||||
|
|
||||||
|
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls.at(-1)![1];
|
||||||
|
expect(serverOpts.daemon).toEqual({ token: "fn_env_dashboard_token" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses FUSION_DAEMON_TOKEN when dashboard token env var is absent", async () => {
|
||||||
|
const { createServer } = await import("@fusion/dashboard");
|
||||||
|
process.env.FUSION_DAEMON_TOKEN = "fn_env_daemon_token";
|
||||||
|
|
||||||
|
await runDashboard(0, { open: false, dev: true });
|
||||||
|
|
||||||
|
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls.at(-1)![1];
|
||||||
|
expect(serverOpts.daemon).toEqual({ token: "fn_env_daemon_token" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables auth entirely with --no-auth and bypasses persisted token lookup", async () => {
|
||||||
|
const { createServer } = await import("@fusion/dashboard");
|
||||||
|
const { DaemonTokenManager } = await import("@fusion/core");
|
||||||
|
|
||||||
|
await runDashboard(0, { open: false, dev: true, noAuth: true });
|
||||||
|
|
||||||
|
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls.at(-1)![1];
|
||||||
|
expect(serverOpts.daemon).toBeUndefined();
|
||||||
|
expect(serverOpts.noAuth).toBe(true);
|
||||||
|
expect(DaemonTokenManager).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("runDashboard — AuthStorage & ModelRegistry wiring", () => {
|
describe("runDashboard — AuthStorage & ModelRegistry wiring", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|||||||
@@ -15,22 +15,36 @@ const {
|
|||||||
mockSelfHealingStop,
|
mockSelfHealingStop,
|
||||||
mockCheckStuckBudget,
|
mockCheckStuckBudget,
|
||||||
mockStuckCheckNow,
|
mockStuckCheckNow,
|
||||||
} = vi.hoisted(() => ({
|
mockResolveGlobalDir,
|
||||||
mockAuthStorage: { getAuth: vi.fn(), setAuth: vi.fn(), getApiKey: vi.fn().mockResolvedValue(undefined) },
|
mockGlobalSettingsGetSettings,
|
||||||
mockModelRegistry: {
|
mockGlobalSettingsUpdateSettings,
|
||||||
registerProvider: vi.fn(),
|
mockDaemonTokenGetOrCreate,
|
||||||
refresh: vi.fn(),
|
} = vi.hoisted(() => {
|
||||||
},
|
delete process.env.FUSION_DASHBOARD_TOKEN;
|
||||||
mockDiscoverAndLoadExtensions: vi.fn().mockResolvedValue({
|
delete process.env.FUSION_DAEMON_TOKEN;
|
||||||
runtime: { pendingProviderRegistrations: [] },
|
delete process.env.FUSION_BEARER_TOKEN;
|
||||||
errors: [],
|
|
||||||
}),
|
return {
|
||||||
mockCreateExtensionRuntime: vi.fn(),
|
mockAuthStorage: { getAuth: vi.fn(), setAuth: vi.fn(), getApiKey: vi.fn().mockResolvedValue(undefined) },
|
||||||
mockSelfHealingStart: vi.fn(),
|
mockModelRegistry: {
|
||||||
mockSelfHealingStop: vi.fn(),
|
registerProvider: vi.fn(),
|
||||||
mockCheckStuckBudget: vi.fn().mockResolvedValue(true),
|
refresh: vi.fn(),
|
||||||
mockStuckCheckNow: vi.fn().mockResolvedValue(undefined),
|
},
|
||||||
}));
|
mockDiscoverAndLoadExtensions: vi.fn().mockResolvedValue({
|
||||||
|
runtime: { pendingProviderRegistrations: [] },
|
||||||
|
errors: [],
|
||||||
|
}),
|
||||||
|
mockCreateExtensionRuntime: vi.fn(),
|
||||||
|
mockSelfHealingStart: vi.fn(),
|
||||||
|
mockSelfHealingStop: vi.fn(),
|
||||||
|
mockCheckStuckBudget: vi.fn().mockResolvedValue(true),
|
||||||
|
mockStuckCheckNow: vi.fn().mockResolvedValue(undefined),
|
||||||
|
mockResolveGlobalDir: vi.fn().mockReturnValue("/tmp/test-global"),
|
||||||
|
mockGlobalSettingsGetSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
mockGlobalSettingsUpdateSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
mockDaemonTokenGetOrCreate: vi.fn().mockResolvedValue("fn_test_dashboard_token"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Minimal mock store backed by EventEmitter so `store.on` works
|
// Minimal mock store backed by EventEmitter so `store.on` works
|
||||||
function makeMockStore() {
|
function makeMockStore() {
|
||||||
@@ -157,6 +171,16 @@ vi.mock("@fusion/core", () => ({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
getEnabledPiExtensionPaths: vi.fn(() => []),
|
getEnabledPiExtensionPaths: vi.fn(() => []),
|
||||||
|
resolveGlobalDir: mockResolveGlobalDir,
|
||||||
|
GlobalSettingsStore: vi.fn().mockImplementation(() => ({
|
||||||
|
getSettings: mockGlobalSettingsGetSettings,
|
||||||
|
updateSettings: mockGlobalSettingsUpdateSettings,
|
||||||
|
})),
|
||||||
|
DaemonTokenManager: vi.fn().mockImplementation(() => ({
|
||||||
|
getOrCreateToken: mockDaemonTokenGetOrCreate,
|
||||||
|
getToken: vi.fn().mockResolvedValue(undefined),
|
||||||
|
generateToken: vi.fn().mockResolvedValue("fn_test_dashboard_token"),
|
||||||
|
})),
|
||||||
getTaskMergeBlocker: vi.fn((task: any) => {
|
getTaskMergeBlocker: vi.fn((task: any) => {
|
||||||
if (task.column !== "in-review") return `task is in '${task.column}', must be in 'in-review'`;
|
if (task.column !== "in-review") return `task is in '${task.column}', must be in 'in-review'`;
|
||||||
if (task.paused) return "task is paused";
|
if (task.paused) return "task is paused";
|
||||||
@@ -732,12 +756,24 @@ function resetGitHubMocks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
delete process.env.FUSION_DASHBOARD_TOKEN;
|
||||||
|
delete process.env.FUSION_DAEMON_TOKEN;
|
||||||
|
delete process.env.FUSION_BEARER_TOKEN;
|
||||||
|
|
||||||
resetGitHubMocks();
|
resetGitHubMocks();
|
||||||
mockExecSync.mockReset();
|
mockExecSync.mockReset();
|
||||||
mockExecSync.mockReturnValue("");
|
mockExecSync.mockReturnValue("");
|
||||||
mockExec.mockClear();
|
mockExec.mockClear();
|
||||||
mockStuckCheckNow.mockReset();
|
mockStuckCheckNow.mockReset();
|
||||||
mockStuckCheckNow.mockResolvedValue(undefined);
|
mockStuckCheckNow.mockResolvedValue(undefined);
|
||||||
|
mockResolveGlobalDir.mockReset();
|
||||||
|
mockResolveGlobalDir.mockReturnValue("/tmp/test-global");
|
||||||
|
mockGlobalSettingsGetSettings.mockReset();
|
||||||
|
mockGlobalSettingsGetSettings.mockResolvedValue({});
|
||||||
|
mockGlobalSettingsUpdateSettings.mockReset();
|
||||||
|
mockGlobalSettingsUpdateSettings.mockResolvedValue({});
|
||||||
|
mockDaemonTokenGetOrCreate.mockReset();
|
||||||
|
mockDaemonTokenGetOrCreate.mockResolvedValue("fn_test_dashboard_token");
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -2646,6 +2682,7 @@ describe("StreamedLogBuffer", () => {
|
|||||||
|
|
||||||
describe("runDashboard — merge stream sink routing", () => {
|
describe("runDashboard — merge stream sink routing", () => {
|
||||||
it("routes streamed merge deltas through log sink without raw stdout writes", async () => {
|
it("routes streamed merge deltas through log sink without raw stdout writes", async () => {
|
||||||
|
process.env.FUSION_DASHBOARD_TOKEN = "fn_test_dashboard_token";
|
||||||
const { TaskStore, AutomationStore, AgentStore, PluginStore, PluginLoader, CentralCore } = await import("@fusion/core");
|
const { TaskStore, AutomationStore, AgentStore, PluginStore, PluginLoader, CentralCore } = await import("@fusion/core");
|
||||||
const { aiMergeTask } = await import("@fusion/engine");
|
const { aiMergeTask } = await import("@fusion/engine");
|
||||||
const { createServer } = await import("@fusion/dashboard");
|
const { createServer } = await import("@fusion/dashboard");
|
||||||
@@ -2726,11 +2763,13 @@ describe("runDashboard — merge stream sink routing", () => {
|
|||||||
|
|
||||||
stdoutWriteSpy.mockRestore();
|
stdoutWriteSpy.mockRestore();
|
||||||
consoleLogSpy.mockRestore();
|
consoleLogSpy.mockRestore();
|
||||||
|
delete process.env.FUSION_DASHBOARD_TOKEN;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("runDashboard runtime logger wiring", () => {
|
describe("runDashboard runtime logger wiring", () => {
|
||||||
it("injects a runtime logger into createServer and preserves non-TTY console fallback", async () => {
|
it("injects a runtime logger into createServer and preserves non-TTY console fallback", async () => {
|
||||||
|
process.env.FUSION_DASHBOARD_TOKEN = "fn_test_dashboard_token";
|
||||||
const { createServer } = await import("@fusion/dashboard");
|
const { createServer } = await import("@fusion/dashboard");
|
||||||
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
@@ -2747,9 +2786,11 @@ describe("runDashboard runtime logger wiring", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
consoleLogSpy.mockRestore();
|
consoleLogSpy.mockRestore();
|
||||||
|
delete process.env.FUSION_DASHBOARD_TOKEN;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes runtime logger output through DashboardLogSink in TTY mode", async () => {
|
it("routes runtime logger output through DashboardLogSink in TTY mode", async () => {
|
||||||
|
process.env.FUSION_DASHBOARD_TOKEN = "fn_test_dashboard_token";
|
||||||
const { createServer } = await import("@fusion/dashboard");
|
const { createServer } = await import("@fusion/dashboard");
|
||||||
const { DashboardLogSink, DashboardTUI } = await import("./dashboard-tui.js");
|
const { DashboardLogSink, DashboardTUI } = await import("./dashboard-tui.js");
|
||||||
|
|
||||||
@@ -2783,6 +2824,7 @@ describe("runDashboard runtime logger wiring", () => {
|
|||||||
tuiStopSpy.mockRestore();
|
tuiStopSpy.mockRestore();
|
||||||
tuiLogSpy.mockRestore();
|
tuiLogSpy.mockRestore();
|
||||||
captureConsoleSpy.mockRestore();
|
captureConsoleSpy.mockRestore();
|
||||||
|
delete process.env.FUSION_DASHBOARD_TOKEN;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import { randomBytes } from "node:crypto";
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { TaskStore, AutomationStore, CentralCore, AgentStore, PluginStore, PluginLoader, getTaskMergeBlocker, getEnabledPiExtensionPaths, isEphemeralAgent } from "@fusion/core";
|
import {
|
||||||
|
TaskStore,
|
||||||
|
AutomationStore,
|
||||||
|
CentralCore,
|
||||||
|
AgentStore,
|
||||||
|
PluginStore,
|
||||||
|
PluginLoader,
|
||||||
|
getTaskMergeBlocker,
|
||||||
|
getEnabledPiExtensionPaths,
|
||||||
|
isEphemeralAgent,
|
||||||
|
DaemonTokenManager,
|
||||||
|
GlobalSettingsStore,
|
||||||
|
resolveGlobalDir,
|
||||||
|
} from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
createServer,
|
createServer,
|
||||||
GitHubClient,
|
GitHubClient,
|
||||||
@@ -307,6 +319,34 @@ async function resolveRuntimeProjectPath(): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveDashboardAuthToken(opts: { noAuth?: boolean; token?: string }): Promise<string | undefined> {
|
||||||
|
if (opts.noAuth) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const explicitToken = opts.token
|
||||||
|
?? process.env.FUSION_DASHBOARD_TOKEN
|
||||||
|
?? process.env.FUSION_DAEMON_TOKEN;
|
||||||
|
|
||||||
|
if (explicitToken) {
|
||||||
|
return explicitToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalDir = resolveGlobalDir();
|
||||||
|
const settingsStore = new GlobalSettingsStore(globalDir);
|
||||||
|
const tokenManager = new DaemonTokenManager(settingsStore);
|
||||||
|
|
||||||
|
if (typeof tokenManager.getOrCreateToken === "function") {
|
||||||
|
return tokenManager.getOrCreateToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingToken = await tokenManager.getToken();
|
||||||
|
if (existingToken) {
|
||||||
|
return existingToken;
|
||||||
|
}
|
||||||
|
return tokenManager.generateToken();
|
||||||
|
}
|
||||||
|
|
||||||
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean; host?: string; noAuth?: boolean; token?: string } = {}) {
|
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean; host?: string; noAuth?: boolean; token?: string } = {}) {
|
||||||
// Default to localhost so the dashboard (and its shell-capable terminal API)
|
// Default to localhost so the dashboard (and its shell-capable terminal API)
|
||||||
// is not exposed on the LAN. Pass --host 0.0.0.0 explicitly to opt-in.
|
// is not exposed on the LAN. Pass --host 0.0.0.0 explicitly to opt-in.
|
||||||
@@ -318,19 +358,15 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
// server is bound to a non-localhost interface (e.g. `pnpm dev dashboard`
|
// server is bound to a non-localhost interface (e.g. `pnpm dev dashboard`
|
||||||
// which injects --host 0.0.0.0 for LAN testing) nearby users can't hit the
|
// which injects --host 0.0.0.0 for LAN testing) nearby users can't hit the
|
||||||
// terminal or exec endpoints uninvited. Precedence:
|
// terminal or exec endpoints uninvited. Precedence:
|
||||||
// 1. `opts.token` — explicit override (mostly for tests)
|
// 1. `opts.token` — explicit override (mostly for tests)
|
||||||
// 2. `FUSION_DASHBOARD_TOKEN` — user-provided env
|
// 2. `FUSION_DASHBOARD_TOKEN` — user-provided env
|
||||||
// 3. `FUSION_DAEMON_TOKEN` — back-compat with daemon mode
|
// 3. `FUSION_DAEMON_TOKEN` — back-compat with daemon mode
|
||||||
// 4. auto-generated random token (printed at startup so the user can auth)
|
// 4. stored token in ~/.fusion/settings.json
|
||||||
|
// 5. newly generated persisted token (first authenticated run only)
|
||||||
// `--no-auth` skips the middleware entirely. The token is embedded in the
|
// `--no-auth` skips the middleware entirely. The token is embedded in the
|
||||||
// launch URL (as `?token=...`) so the user can click once and the browser
|
// launch URL (as `?token=...`) so the user can click once and the browser
|
||||||
// stores it to localStorage for subsequent loads.
|
// stores it to localStorage for subsequent loads.
|
||||||
const dashboardAuthToken: string | undefined = opts.noAuth
|
const dashboardAuthToken = await resolveDashboardAuthToken(opts);
|
||||||
? undefined
|
|
||||||
: opts.token
|
|
||||||
?? process.env.FUSION_DASHBOARD_TOKEN
|
|
||||||
?? process.env.FUSION_DAEMON_TOKEN
|
|
||||||
?? `fn_${randomBytes(16).toString("hex")}`;
|
|
||||||
|
|
||||||
// Single sink/logger pair for all dashboard command diagnostics.
|
// Single sink/logger pair for all dashboard command diagnostics.
|
||||||
// In TTY mode this routes to DashboardTUI; in non-TTY mode it falls back to console.*.
|
// In TTY mode this routes to DashboardTUI; in non-TTY mode it falls back to console.*.
|
||||||
|
|||||||
@@ -64,6 +64,30 @@ export class DaemonTokenManager {
|
|||||||
return settings.daemonToken;
|
return settings.daemonToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the existing daemon token or create/persist one if missing.
|
||||||
|
*
|
||||||
|
* Safe for concurrent callers: if another process writes the token between
|
||||||
|
* the initial read and generateToken(), this method re-reads and returns the
|
||||||
|
* persisted token instead of failing.
|
||||||
|
*/
|
||||||
|
async getOrCreateToken(): Promise<string> {
|
||||||
|
const existing = await this.getToken();
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.generateToken();
|
||||||
|
} catch (error) {
|
||||||
|
const afterRace = await this.getToken();
|
||||||
|
if (afterRace) {
|
||||||
|
return afterRace;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that a provided token matches the stored token.
|
* Validate that a provided token matches the stored token.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user