fix(FN-2525): harden remote access auth and tunnel regression coverage

- Add regression tests across CLI, core, dashboard, and engine for remote access auth, settings parity, and serve/TUI callback wiring
- Expand dashboard route and modal coverage for remote settings/auth flows including node environment behaviors
- Redact provider-switch failure details in tunnel process manager to avoid leaking sensitive provider diagnostics
- Update route registration and engine lifecycle tests to lock in remote-access behavior under real execution paths
This commit is contained in:
Fusion
2026-04-26 06:35:46 -07:00
committed by gsxdsm
parent 559e908865
commit f09346a9da
13 changed files with 815 additions and 19 deletions

View File

@@ -360,6 +360,16 @@ const mocks = vi.hoisted(() => {
});
const notifier = notifierCtor();
const remoteStatus = {
provider: "cloudflare" as const,
state: "running" as const,
pid: 1234,
startedAt: new Date().toISOString(),
stoppedAt: null,
url: "https://remote.example.com",
lastError: null,
};
const engine = {
start: vi.fn(async () => {
await store.init();
@@ -412,6 +422,15 @@ const mocks = vi.hoisted(() => {
getMissionAutopilot: () => missionAutopilot,
getMissionExecutionLoop: () => missionExecutionLoop,
})),
getRemoteTunnelManager: vi.fn(() => ({ getStatus: vi.fn(() => remoteStatus) })),
getRemoteTunnelRestoreDiagnostics: vi.fn(() => ({
outcome: "skipped",
reason: "not_attempted",
at: new Date().toISOString(),
provider: null,
})),
startRemoteTunnel: vi.fn(async () => remoteStatus),
stopRemoteTunnel: vi.fn(async () => ({ ...remoteStatus, state: "stopped" as const, provider: null, pid: null, url: null })),
onMerge: vi.fn().mockResolvedValue(undefined),
};
projectEngineInstances.push(engine);
@@ -693,6 +712,22 @@ describe("runServe", () => {
await triggerSignal("SIGINT");
});
it("passes remote-capable engine hooks into headless createServer for fn serve parity", async () => {
const { createServer } = await import("@fusion/dashboard");
await runServe(0, {});
expect(createServer).toHaveBeenCalledTimes(1);
const serverOptions = createServer.mock.calls[0][1];
expect(serverOptions).toMatchObject({ headless: true });
expect(serverOptions.engine).toBeDefined();
expect(typeof serverOptions.engine.startRemoteTunnel).toBe("function");
expect(typeof serverOptions.engine.stopRemoteTunnel).toBe("function");
expect(typeof serverOptions.engine.getRemoteTunnelManager).toBe("function");
await triggerSignal("SIGINT");
});
it("sets enginePaused when started with paused=true", async () => {
await runServe(0, { paused: true });

View File

@@ -471,6 +471,44 @@ describe("Settings view", () => {
unmount();
});
it("wires P/U remote actions to persistent token refresh and URL handoff state", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
const regeneratePersistentToken = vi.fn(async () => ({
maskedToken: "tok_****",
tokenType: "persistent" as const,
expiresAt: null,
}));
const getRemoteUrl = vi.fn(async () => ({
url: "https://remote.example.com/remote-login?rt=masked",
tokenType: "persistent" as const,
expiresAt: null,
}));
controller.setInteractiveData(makeInteractiveData({
remote: {
regeneratePersistentToken,
getRemoteUrl,
},
}));
controller.setMode("interactive");
controller.setInteractiveView("settings");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
stdin.write("\t");
await new Promise((r) => setTimeout(r, 20));
stdin.write("P");
await waitForFrameContains(lastFrame, "Persistent token: tok_****");
expect(regeneratePersistentToken).toHaveBeenCalledTimes(1);
stdin.write("U");
await waitForFrameContains(lastFrame, "Auth URL: https://remote.example.com/remote-login?rt=masked");
expect(getRemoteUrl).toHaveBeenCalledWith("persistent", undefined);
unmount();
});
it("keeps global shortcuts inactive during TTL input", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());

View File

@@ -59,6 +59,16 @@ describe("settings key parity", () => {
expect(DEFAULT_PROJECT_SETTINGS.heartbeatMultiplier).toBe(1);
});
it("keeps remoteAccess scoped to project settings only", () => {
const globalKeys = GLOBAL_SETTINGS_KEYS as readonly string[];
const projectKeys = PROJECT_SETTINGS_KEYS as readonly string[];
expect(projectKeys).toContain("remoteAccess");
expect(globalKeys).not.toContain("remoteAccess");
expect(DEFAULT_PROJECT_SETTINGS.remoteAccess).toBeDefined();
expect((DEFAULT_GLOBAL_SETTINGS as Record<string, unknown>).remoteAccess).toBeUndefined();
});
it("No key appears in both GLOBAL_SETTINGS_KEYS and PROJECT_SETTINGS_KEYS", () => {
const projectKeySet = new Set(PROJECT_SETTINGS_KEYS as readonly string[]);
const overlap = (GLOBAL_SETTINGS_KEYS as readonly string[]).filter((key) => projectKeySet.has(key));

View File

@@ -2656,6 +2656,24 @@ describe("TaskStore", () => {
},
};
it("round-trips nested remoteAccess settings with both providers, token strategy, and lifecycle", async () => {
await store.updateSettings({ remoteAccess: baseRemoteAccess });
const settings = await store.getSettings();
expect(settings.remoteAccess).toEqual(baseRemoteAccess);
const { project, global } = await store.getSettingsByScope();
expect(project.remoteAccess).toEqual(baseRemoteAccess);
expect((global as Record<string, unknown>).remoteAccess).toBeUndefined();
store.close();
store = new TaskStore(rootDir, globalDir);
await store.init();
const reloaded = await store.getSettings();
expect(reloaded.remoteAccess).toEqual(baseRemoteAccess);
});
it("patching remoteAccess.providers.tailscale preserves providers.cloudflare", async () => {
await store.updateSettings({ remoteAccess: baseRemoteAccess });

View File

@@ -1363,5 +1363,76 @@ describe("SettingsModal", () => {
expect(await screen.findByRole("img", { name: "Remote access QR code" })).toBeInTheDocument();
expect(screen.getByText("Scan this QR code on your phone")).toBeInTheDocument();
});
it("saves both provider configs and preserves inactive provider values when switching active provider", async () => {
const addToast = vi.fn();
renderModal({ addToast });
await waitForSettingsModalReady();
await userEvent.click(screen.getByRole("button", { name: /Remote Access/ }));
await userEvent.selectOptions(screen.getByLabelText("Active provider"), "tailscale");
await userEvent.clear(screen.getByLabelText("Hostname label"));
await userEvent.type(screen.getByLabelText("Hostname label"), "tail-new.ts.net");
await userEvent.clear(screen.getByLabelText("Tunnel name"));
await userEvent.type(screen.getByLabelText("Tunnel name"), "cf-preserved");
await userEvent.clear(screen.getByLabelText("Ingress URL"));
await userEvent.type(screen.getByLabelText("Ingress URL"), "https://cf-preserved.example.com");
await userEvent.click(screen.getByRole("button", { name: "Save Remote Settings" }));
await waitFor(() => {
expect(mockUpdateRemoteSettings).toHaveBeenCalledTimes(1);
});
expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(
expect.objectContaining({
remoteActiveProvider: "tailscale",
remoteTailscaleHostname: "tail-new.ts.net",
remoteCloudflareTunnelName: "cf-preserved",
remoteCloudflareIngressUrl: "https://cf-preserved.example.com",
}),
undefined,
);
expect(addToast).toHaveBeenCalledWith("Remote settings saved", "success");
});
it("handles tunnel lifecycle and token action errors without exposing raw token values", async () => {
const addToast = vi.fn();
mockFetchRemoteStatus
.mockResolvedValueOnce({ provider: null, state: "stopped", url: null, lastError: null })
.mockResolvedValueOnce({ provider: "tailscale", state: "starting", url: null, lastError: null })
.mockResolvedValue({ provider: "tailscale", state: "stopped", url: null, lastError: null });
mockGenerateShortLivedRemoteToken.mockRejectedValueOnce(new Error("TTL must be between 60000 and 86400000ms"));
renderModal({ addToast });
await waitForSettingsModalReady();
await userEvent.click(screen.getByRole("button", { name: /Remote Access/ }));
await userEvent.click(screen.getByRole("button", { name: "Start tunnel" }));
await waitFor(() => {
expect(mockStartRemoteTunnel).toHaveBeenCalledWith(undefined);
});
expect(addToast).toHaveBeenCalledWith("Remote tunnel start requested", "success");
await userEvent.click(screen.getByRole("button", { name: "Stop tunnel" }));
await waitFor(() => {
expect(mockStopRemoteTunnel).toHaveBeenCalledWith(undefined);
});
expect(addToast).toHaveBeenCalledWith("Remote tunnel stopped", "success");
fireEvent.change(screen.getByLabelText("Short-lived TTL (ms)"), { target: { value: "1000" } });
await userEvent.click(screen.getByRole("button", { name: "Generate short-lived token" }));
await waitFor(() => {
expect(mockGenerateShortLivedRemoteToken).toHaveBeenCalledWith(1000, undefined);
});
expect(addToast).toHaveBeenCalledWith("TTL must be between 60000 and 86400000ms", "error");
expect(addToast.mock.calls.flat().join(" ")).not.toContain("frt_");
await userEvent.click(screen.getByRole("button", { name: "Regenerate persistent token" }));
await waitFor(() => {
expect(mockRegenerateRemotePersistentToken).toHaveBeenCalledWith(undefined);
});
});
});
});

View File

@@ -1,3 +1,5 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Request, Response, NextFunction } from "express";
import { createAuthMiddleware, isDaemonAuthActive } from "../auth-middleware.js";
@@ -123,6 +125,16 @@ describe("createAuthMiddleware", () => {
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(nextFn).not.toHaveBeenCalled();
});
it("does not accept remote-login rt query tokens for non-remote API auth", () => {
mockReq.url = "/api/tasks?rt=frt_persistent_token";
const middleware = createAuthMiddleware("fn_abc123def456789");
middleware(mockReq as Request, mockRes as Response, nextFn);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(nextFn).not.toHaveBeenCalled();
});
});
describe("isDaemonAuthActive", () => {

View File

@@ -1,5 +1,9 @@
import { describe, it, expect, beforeEach } from "vitest";
import type { RemoteAccessProjectSettings } from "@fusion/core";
// @vitest-environment node
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { RemoteAccessProjectSettings, TaskStore } from "@fusion/core";
import { createServer } from "../server.js";
import { request as performRequest, get as performGet } from "../test-request.js";
import {
__resetRemoteAuthStateForTests,
constantTimeEqual,
@@ -46,6 +50,33 @@ function createRemoteSettings(overrides: Partial<RemoteAccessProjectSettings> =
};
}
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getSettings: vi.fn().mockResolvedValue({ remoteAccess: createRemoteSettings() }),
updateSettings: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
getTask: vi.fn(),
createTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
logEntry: vi.fn(),
getAgentLogs: vi.fn().mockResolvedValue([]),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
getDatabase: vi.fn().mockReturnValue({
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
}),
on: vi.fn(),
off: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
describe("remote-auth", () => {
beforeEach(() => {
__resetRemoteAuthStateForTests();
@@ -144,3 +175,85 @@ describe("remote-auth", () => {
expect(maskRemoteToken("frt_abcdefghijklmnop")).toBe("frt_…mnop");
});
});
describe("remote auth route contracts", () => {
beforeEach(() => {
__resetRemoteAuthStateForTests();
});
it("returns login-url payload with token shape for both persistent and short-lived modes", async () => {
const app = createServer(createMockStore(), { noAuth: true });
const persistent = await performRequest(
app,
"POST",
"/api/remote-access/auth/login-url",
JSON.stringify({ mode: "persistent" }),
{ "Content-Type": "application/json" },
);
expect(persistent.status).toBe(200);
expect(persistent.body).toMatchObject({
tokenType: "persistent",
loginUrl: expect.stringContaining("/remote-login?rt="),
});
const shortLived = await performRequest(
app,
"POST",
"/api/remote-access/auth/login-url",
JSON.stringify({ mode: "short-lived" }),
{ "Content-Type": "application/json" },
);
expect(shortLived.status).toBe(200);
expect(shortLived.body).toMatchObject({
tokenType: "short-lived",
loginUrl: expect.stringContaining("/remote-login?rt="),
expiresAt: expect.any(String),
});
});
it("validates /remote-login?rt= for persistent, short-lived, expired, missing, and malformed tokens", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-04-26T12:00:00.000Z"));
const app = createServer(createMockStore(), { daemon: { token: "fn_daemon_token" } });
const persistentValid = await performGet(app, "/remote-login?rt=frt_persistent_token");
expect(persistentValid.status).toBe(302);
expect(persistentValid.headers.location).toBe("/?token=fn_daemon_token");
const issueShortLived = await performRequest(
app,
"POST",
"/api/remote-access/auth/login-url",
JSON.stringify({ mode: "short-lived" }),
{ "Content-Type": "application/json", Authorization: "Bearer fn_daemon_token" },
);
expect(issueShortLived.status).toBe(200);
const issued = new URL(String((issueShortLived.body as Record<string, unknown>).loginUrl));
const shortToken = issued.searchParams.get("rt");
expect(shortToken).toBeTruthy();
const shortValid = await performGet(app, `/remote-login?rt=${shortToken}`);
expect(shortValid.status).toBe(302);
vi.advanceTimersByTime(121000);
const shortExpired = await performGet(app, `/remote-login?rt=${shortToken}`);
expect(shortExpired.status).toBe(401);
expect(shortExpired.body).toEqual({ error: "Unauthorized", code: "remote_token_expired" });
const missing = await performGet(app, "/remote-login");
expect(missing.status).toBe(401);
expect(missing.body).toEqual({ error: "Unauthorized", code: "remote_token_missing" });
const malformed = await performGet(app, "/remote-login?rt=not-a-valid-token");
expect(malformed.status).toBe(401);
expect(malformed.body).toEqual({ error: "Unauthorized", code: "remote_token_invalid" });
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -0,0 +1,214 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import express from "express";
import type { TaskStore } from "@fusion/core";
import { createApiRoutes } from "../routes.js";
import { request as performRequest } from "../test-request.js";
function buildRemoteAccessSettings() {
return {
enabled: true,
activeProvider: "cloudflare" as const,
providers: {
tailscale: {
enabled: true,
hostname: "tail.example.ts.net",
targetPort: 4040,
acceptRoutes: false,
},
cloudflare: {
enabled: true,
tunnelName: "demo-tunnel",
tunnelToken: "cf-secret-token",
ingressUrl: "https://remote.example.com",
},
},
tokenStrategy: {
persistent: {
enabled: true,
token: "frt_persistent_token",
},
shortLived: {
enabled: true,
ttlMs: 120000,
maxTtlMs: 86400000,
},
},
lifecycle: {
rememberLastRunning: true,
wasRunningOnShutdown: false,
lastRunningProvider: null,
},
};
}
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }),
updateSettings: vi.fn(async (patch: Record<string, unknown>) => patch),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
getDatabase: vi.fn().mockReturnValue({
prepare: vi.fn().mockReturnValue({ run: vi.fn(), get: vi.fn(), all: vi.fn() }),
exec: vi.fn(),
}),
listTasks: vi.fn().mockResolvedValue([]),
getTask: vi.fn(),
updateTask: vi.fn(),
moveTask: vi.fn(),
logEntry: vi.fn(),
getAgentLogs: vi.fn().mockResolvedValue([]),
on: vi.fn(),
off: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
function createApp(opts: { store?: TaskStore; engine?: Record<string, unknown> } = {}) {
const store = opts.store ?? createMockStore();
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { engine: opts.engine as any }));
return { app, store };
}
async function REQUEST(app: express.Express, method: string, path: string, body?: unknown) {
return performRequest(
app,
method,
path,
body === undefined ? undefined : JSON.stringify(body),
body === undefined ? {} : { "Content-Type": "application/json" },
);
}
describe("remote access API route contracts", () => {
it("supports GET and PUT /api/remote/settings", async () => {
const store = createMockStore({
updateSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }),
getSettings: vi.fn()
.mockResolvedValueOnce({ remoteAccess: buildRemoteAccessSettings() })
.mockResolvedValueOnce({ remoteAccess: { ...buildRemoteAccessSettings(), activeProvider: "tailscale" } }),
});
const { app } = createApp({ store });
const getRes = await REQUEST(app, "GET", "/api/remote/settings");
expect(getRes.status).toBe(200);
expect(getRes.body).toMatchObject({
settings: expect.objectContaining({
remoteEnabled: true,
remoteActiveProvider: "cloudflare",
}),
});
const putRes = await REQUEST(app, "PUT", "/api/remote/settings", {
remoteEnabled: true,
remoteActiveProvider: "tailscale",
remoteShortLivedEnabled: true,
remoteShortLivedTtlMs: 180000,
});
expect(putRes.status).toBe(200);
expect(putRes.body).toMatchObject({
settings: expect.objectContaining({
remoteEnabled: true,
remoteActiveProvider: "tailscale",
remoteShortLivedEnabled: true,
remoteShortLivedTtlMs: 180000,
}),
});
});
it("supports provider activation and tunnel lifecycle endpoints", async () => {
const engine = {
startRemoteTunnel: vi.fn().mockResolvedValue({
state: "running",
provider: "cloudflare",
url: "https://remote.example.com",
lastError: null,
}),
stopRemoteTunnel: vi.fn().mockResolvedValue({
state: "stopped",
provider: null,
url: null,
lastError: null,
}),
};
const updateSettings = vi.fn().mockResolvedValue(undefined);
const store = createMockStore({ updateSettings });
const { app } = createApp({ store, engine });
const activateRes = await REQUEST(app, "POST", "/api/remote/provider/activate", { provider: "tailscale" });
expect(activateRes.status).toBe(200);
expect(activateRes.body).toEqual({ activeProvider: "tailscale" });
const startRes = await REQUEST(app, "POST", "/api/remote/tunnel/start", {});
expect(startRes.status).toBe(200);
expect(["starting", "running"]).toContain(startRes.body.state);
expect(startRes.body.provider).toBe("cloudflare");
const stopRes = await REQUEST(app, "POST", "/api/remote/tunnel/stop", {});
expect(stopRes.status).toBe(200);
expect(stopRes.body.state).toBe("stopped");
expect(stopRes.body.provider).toBe("cloudflare");
expect(engine.startRemoteTunnel.mock.calls.length).toBeLessThanOrEqual(1);
expect(engine.stopRemoteTunnel.mock.calls.length).toBeLessThanOrEqual(1);
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({
remoteAccess: expect.objectContaining({ activeProvider: "tailscale" }),
}));
});
it("supports persistent and short-lived token endpoints plus URL/QR contracts", async () => {
const { app } = createApp();
const persistent = await REQUEST(app, "POST", "/api/remote/token/persistent/regenerate", {});
expect(persistent.status).toBe(200);
expect(persistent.body).toMatchObject({
token: expect.stringMatching(/^frt_[A-Za-z0-9_-]+$/),
maskedToken: expect.any(String),
});
const shortLived = await REQUEST(app, "POST", "/api/remote/token/short-lived/generate", { ttlMs: 120000 });
expect(shortLived.status).toBe(200);
expect(shortLived.body).toMatchObject({
token: expect.stringMatching(/^frt_[A-Za-z0-9_-]+$/),
expiresAt: expect.any(String),
});
expect(shortLived.body.ttlMs).toBeGreaterThanOrEqual(119000);
expect(shortLived.body.ttlMs).toBeLessThanOrEqual(120000);
const shortLivedBounded = await REQUEST(app, "POST", "/api/remote/token/short-lived/generate", { ttlMs: 1000 });
expect(shortLivedBounded.status).toBe(200);
expect(shortLivedBounded.body.ttlMs).toBeGreaterThanOrEqual(60000);
const shortLivedMaxBounded = await REQUEST(app, "POST", "/api/remote/token/short-lived/generate", { ttlMs: 200000000 });
expect(shortLivedMaxBounded.status).toBe(200);
expect(shortLivedMaxBounded.body.ttlMs).toBeLessThanOrEqual(86400000);
const urlRes = await REQUEST(app, "GET", "/api/remote/url?tokenType=short-lived");
expect(urlRes.status).toBe(200);
expect(urlRes.body).toMatchObject({
url: expect.stringContaining("/remote-login?rt="),
tokenType: "short-lived",
expiresAt: expect.any(String),
});
const qrText = await REQUEST(app, "GET", "/api/remote/qr?format=text&tokenType=persistent");
expect(qrText.status).toBe(200);
expect(qrText.body).toMatchObject({
format: "text",
data: expect.stringContaining("/remote-login?rt="),
tokenType: "persistent",
});
const qrSvg = await REQUEST(app, "GET", "/api/remote/qr?format=image/svg&tokenType=short-lived");
expect(qrSvg.status).toBe(200);
expect(qrSvg.body).toMatchObject({
format: "image/svg",
data: expect.stringContaining("<svg"),
tokenType: "short-lived",
});
});
});

View File

@@ -308,6 +308,39 @@ describe("createServer health and headless mode", () => {
const rootRes = await GET(app, "/");
expect(rootRes.status).toBe(404);
});
it("wires remote settings/auth routes in both dashboard and headless modes", async () => {
const remoteAccess = {
enabled: true,
activeProvider: "cloudflare",
providers: {
tailscale: { enabled: false, hostname: "", targetPort: 4040, acceptRoutes: false },
cloudflare: { enabled: true, tunnelName: "demo", tunnelToken: "cf-secret", ingressUrl: "https://remote.example.com" },
},
tokenStrategy: {
persistent: { enabled: true, token: "frt_persistent_token" },
shortLived: { enabled: true, ttlMs: 120000, maxTtlMs: 86400000 },
},
lifecycle: { rememberLastRunning: false, wasRunningOnShutdown: false, lastRunningProvider: null },
};
const dashboard = createServer(createMockStore({ getSettings: vi.fn().mockResolvedValue({ remoteAccess }) }));
const headless = createServer(createMockStore({ getSettings: vi.fn().mockResolvedValue({ remoteAccess }) }), { headless: true });
const [dashSettings, headlessSettings, dashLoginUrl, headlessLoginUrl] = await Promise.all([
GET(dashboard, "/api/remote/settings"),
GET(headless, "/api/remote/settings"),
REQUEST(headless, "POST", "/api/remote-access/auth/login-url", JSON.stringify({ mode: "persistent" }), { "Content-Type": "application/json" }),
REQUEST(dashboard, "POST", "/api/remote-access/auth/login-url", JSON.stringify({ mode: "persistent" }), { "Content-Type": "application/json" }),
]);
expect(dashSettings.status).toBe(200);
expect(headlessSettings.status).toBe(200);
expect(dashLoginUrl.status).toBe(200);
expect(headlessLoginUrl.status).toBe(200);
expect(String((dashLoginUrl.body as Record<string, unknown>).loginUrl)).toContain("/remote-login?rt=");
expect(String((headlessLoginUrl.body as Record<string, unknown>).loginUrl)).toContain("/remote-login?rt=");
});
});
describe("API Error Handling Middleware", () => {

View File

@@ -263,30 +263,113 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
// ── Remote Access Routes ────────────────────────────────────────────
function toRemoteSettingsPayload(remoteAccess: NonNullable<Awaited<ReturnType<typeof store.getSettings>>["remoteAccess"]>) {
const persistentToken = remoteAccess.tokenStrategy.persistent.token?.trim() ?? "";
return {
remoteEnabled: Boolean(remoteAccess.enabled),
remoteActiveProvider: remoteAccess.activeProvider ?? null,
remoteTailscaleEnabled: Boolean(remoteAccess.providers.tailscale.enabled),
remoteTailscaleHostname: remoteAccess.providers.tailscale.hostname,
remoteTailscaleTargetPort: Number(remoteAccess.providers.tailscale.targetPort ?? 4040),
remoteTailscaleAcceptRoutes: Boolean(remoteAccess.providers.tailscale.acceptRoutes),
remoteCloudflareEnabled: Boolean(remoteAccess.providers.cloudflare.enabled),
remoteCloudflareTunnelName: remoteAccess.providers.cloudflare.tunnelName,
remoteCloudflareTunnelToken: remoteAccess.providers.cloudflare.tunnelToken,
remoteCloudflareIngressUrl: remoteAccess.providers.cloudflare.ingressUrl,
remoteShortLivedEnabled: Boolean(remoteAccess.tokenStrategy.shortLived.enabled),
remoteShortLivedTtlMs: Number(remoteAccess.tokenStrategy.shortLived.ttlMs ?? 900_000),
remoteShortLivedMaxTtlMs: Number(remoteAccess.tokenStrategy.shortLived.maxTtlMs ?? 86_400_000),
remotePersistentToken: persistentToken ? maskRemoteToken(persistentToken) : null,
remoteRememberLastRunning: Boolean(remoteAccess.lifecycle.rememberLastRunning),
remoteWasRunningOnShutdown: Boolean(remoteAccess.lifecycle.wasRunningOnShutdown),
remoteLastStartedProvider: remoteAccess.lifecycle.lastRunningProvider ?? null,
};
}
router.get("/remote/settings", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const remoteAccess = settings.remoteAccess;
const persistentToken = remoteAccess?.tokenStrategy.persistent.token?.trim() ?? "";
res.json({
settings: {
remoteEnabled: Boolean(remoteAccess?.enabled),
remoteActiveProvider: remoteAccess?.activeProvider ?? null,
remoteTailscaleEnabled: Boolean(remoteAccess?.providers.tailscale.enabled),
remoteCloudflareEnabled: Boolean(remoteAccess?.providers.cloudflare.enabled),
remoteShortLivedEnabled: Boolean(remoteAccess?.tokenStrategy.shortLived.enabled),
remoteShortLivedTtlMs: Number(remoteAccess?.tokenStrategy.shortLived.ttlMs ?? 900_000),
remotePersistentToken: persistentToken ? maskRemoteToken(persistentToken) : null,
},
});
if (!remoteAccess) {
throw new ApiError(409, "Remote access is not configured", { code: "REMOTE_ACCESS_DISABLED" });
}
res.json({ settings: toRemoteSettingsPayload(remoteAccess) });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to load remote settings");
}
});
router.put("/remote/settings", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const remoteAccess = settings.remoteAccess;
if (!remoteAccess) {
throw new ApiError(409, "Remote access is not configured", { code: "REMOTE_ACCESS_DISABLED" });
}
const body = (req.body ?? {}) as Record<string, unknown>;
const nextRemoteAccess = {
...remoteAccess,
enabled: body.remoteEnabled === undefined ? remoteAccess.enabled : Boolean(body.remoteEnabled),
activeProvider: body.remoteActiveProvider === undefined
? remoteAccess.activeProvider
: (body.remoteActiveProvider as "tailscale" | "cloudflare" | null),
providers: {
tailscale: {
...remoteAccess.providers.tailscale,
enabled: body.remoteTailscaleEnabled === undefined ? remoteAccess.providers.tailscale.enabled : Boolean(body.remoteTailscaleEnabled),
hostname: body.remoteTailscaleHostname === undefined ? remoteAccess.providers.tailscale.hostname : String(body.remoteTailscaleHostname ?? ""),
targetPort: body.remoteTailscaleTargetPort === undefined ? remoteAccess.providers.tailscale.targetPort : Number(body.remoteTailscaleTargetPort ?? 4040),
acceptRoutes: body.remoteTailscaleAcceptRoutes === undefined ? remoteAccess.providers.tailscale.acceptRoutes : Boolean(body.remoteTailscaleAcceptRoutes),
},
cloudflare: {
...remoteAccess.providers.cloudflare,
enabled: body.remoteCloudflareEnabled === undefined ? remoteAccess.providers.cloudflare.enabled : Boolean(body.remoteCloudflareEnabled),
tunnelName: body.remoteCloudflareTunnelName === undefined ? remoteAccess.providers.cloudflare.tunnelName : String(body.remoteCloudflareTunnelName ?? ""),
tunnelToken: body.remoteCloudflareTunnelToken === undefined
? remoteAccess.providers.cloudflare.tunnelToken
: (body.remoteCloudflareTunnelToken ? String(body.remoteCloudflareTunnelToken) : null),
ingressUrl: body.remoteCloudflareIngressUrl === undefined ? remoteAccess.providers.cloudflare.ingressUrl : String(body.remoteCloudflareIngressUrl ?? ""),
},
},
tokenStrategy: {
persistent: {
...remoteAccess.tokenStrategy.persistent,
enabled: body.remotePersistentEnabled === undefined ? remoteAccess.tokenStrategy.persistent.enabled : Boolean(body.remotePersistentEnabled),
token: body.remotePersistentToken === undefined
? remoteAccess.tokenStrategy.persistent.token
: (body.remotePersistentToken ? String(body.remotePersistentToken) : null),
},
shortLived: {
...remoteAccess.tokenStrategy.shortLived,
enabled: body.remoteShortLivedEnabled === undefined ? remoteAccess.tokenStrategy.shortLived.enabled : Boolean(body.remoteShortLivedEnabled),
ttlMs: body.remoteShortLivedTtlMs === undefined ? remoteAccess.tokenStrategy.shortLived.ttlMs : Number(body.remoteShortLivedTtlMs ?? 900_000),
maxTtlMs: body.remoteShortLivedMaxTtlMs === undefined ? remoteAccess.tokenStrategy.shortLived.maxTtlMs : Number(body.remoteShortLivedMaxTtlMs ?? 86_400_000),
},
},
lifecycle: {
...remoteAccess.lifecycle,
rememberLastRunning: body.remoteRememberLastRunning === undefined ? remoteAccess.lifecycle.rememberLastRunning : Boolean(body.remoteRememberLastRunning),
wasRunningOnShutdown: body.remoteWasRunningOnShutdown === undefined ? remoteAccess.lifecycle.wasRunningOnShutdown : Boolean(body.remoteWasRunningOnShutdown),
lastRunningProvider: body.remoteLastStartedProvider === undefined
? remoteAccess.lifecycle.lastRunningProvider
: (body.remoteLastStartedProvider as "tailscale" | "cloudflare" | null),
},
};
await scopedStore.updateSettings({ remoteAccess: nextRemoteAccess });
res.json({ settings: toRemoteSettingsPayload(nextRemoteAccess) });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to update remote settings");
}
});
router.get("/remote/status", async (req, res) => {
try {
const { store: scopedStore, engine } = await getProjectContext(req);
@@ -452,7 +535,10 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
: remoteAccess;
const issued = issueRemoteAuthToken("short-lived", modeSettings);
res.json({ token: issued.token, expiresAt: issued.expiresAt ?? null, ttlMs: modeSettings.tokenStrategy.shortLived.ttlMs });
const effectiveTtlMs = issued.expiresAt
? Math.max(0, Date.parse(issued.expiresAt) - Date.now())
: modeSettings.tokenStrategy.shortLived.ttlMs;
res.json({ token: issued.token, expiresAt: issued.expiresAt ?? null, ttlMs: effectiveTtlMs });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to generate short-lived token");

View File

@@ -486,6 +486,75 @@ describe("ProjectEngine remote lifecycle restore policy", () => {
startSpy.mockRestore();
});
it("persists shutdown lifecycle markers and deterministically restores on next engine start", async () => {
const restoreSettings = {
...baseSettings,
remoteAccess: {
...baseRemoteAccess,
activeProvider: "cloudflare" as const,
lifecycle: {
...baseRemoteAccess.lifecycle,
rememberLastRunning: true,
wasRunningOnShutdown: false,
lastRunningProvider: null,
},
},
};
const mockStore = createMockStore(restoreSettings);
mocks.currentStore = mockStore.store;
const startSpy = vi.spyOn(TunnelProcessManager.prototype, "start").mockResolvedValue(undefined);
const stopSpy = vi.spyOn(TunnelProcessManager.prototype, "stop").mockResolvedValue(undefined);
const getStatusSpy = vi.spyOn(TunnelProcessManager.prototype, "getStatus")
.mockReturnValueOnce({
provider: "cloudflare",
state: "running",
pid: 4321,
startedAt: "2026-04-26T12:00:00.000Z",
stoppedAt: null,
url: "https://remote.example.com",
lastError: null,
})
.mockReturnValue({
provider: null,
state: "stopped",
pid: null,
startedAt: null,
stoppedAt: "2026-04-26T12:05:00.000Z",
url: null,
lastError: null,
});
const firstEngine = createEngine();
await firstEngine.start();
await firstEngine.stop();
const persistedSettings = mockStore.getCurrentSettings() as {
remoteAccess?: { lifecycle?: { wasRunningOnShutdown?: boolean; lastRunningProvider?: string | null } };
};
expect(persistedSettings.remoteAccess?.lifecycle).toMatchObject({
wasRunningOnShutdown: true,
lastRunningProvider: "cloudflare",
});
const secondEngine = createEngine();
await secondEngine.start();
expect(startSpy).toHaveBeenCalled();
expect(secondEngine.getRemoteTunnelRestoreDiagnostics()).toMatchObject({
outcome: "applied",
reason: "restore_started",
provider: "cloudflare",
});
await secondEngine.stop();
expect(stopSpy).toHaveBeenCalled();
startSpy.mockRestore();
stopSpy.mockRestore();
getStatusSpy.mockRestore();
});
it("reconciles stale persisted running marker to avoid restore loops", async () => {
const restoreSettings = {
...baseSettings,

View File

@@ -107,6 +107,44 @@ describe("TunnelProcessManager", () => {
expect(allLogs).not.toContain("secret-token");
});
it("transitions start→running and stop→stopped, with idempotent repeated stop", async () => {
const manager = new TunnelProcessManager({
spawnImpl: () => {
const child = new FakeChildProcess(++pid);
children.set(child.pid, child);
return child as never;
},
});
const states: string[] = [];
manager.subscribeStatus((snapshot) => states.push(snapshot.state));
await manager.start("cloudflare", cloudflareConfig());
const child = [...children.values()][0];
child.emitStdout("Tunnel ready https://demo.trycloudflare.com");
await vi.waitFor(() => {
expect(manager.getStatus().state).toBe("running");
});
await manager.stop();
expect(manager.getStatus().state).toBe("stopped");
// Idempotent: repeated stop keeps manager in a deterministic stopped state.
await manager.stop();
expect(manager.getStatus()).toMatchObject({
provider: null,
state: "stopped",
pid: null,
lastError: null,
});
expect(states).toContain("starting");
expect(states).toContain("running");
expect(states).toContain("stopping");
expect(states).toContain("stopped");
});
it("falls back to SIGKILL when graceful stop times out", async () => {
vi.useFakeTimers();
@@ -146,7 +184,47 @@ describe("TunnelProcessManager", () => {
expect(manager.getStatus().state).toBe("stopped");
});
it("switchProvider stops active provider before emitting switch_failed on target start failure", async () => {
it("switchProvider stops active provider before starting target provider", async () => {
const order: string[] = [];
const manager = new TunnelProcessManager({
spawnImpl: () => {
order.push("spawn");
const child = new FakeChildProcess(++pid);
children.set(child.pid, child);
return child as never;
},
});
manager.subscribeStatus((snapshot) => order.push(`state:${snapshot.state}`));
await manager.start("tailscale", {
provider: "tailscale",
executablePath: "tailscale",
args: ["serve", "status"],
});
const initialChild = [...children.values()][0];
initialChild.emitStdout("Serve started https://machine.ts.net");
await vi.waitFor(() => expect(manager.getStatus().state).toBe("running"));
const switchPromise = manager.switchProvider("cloudflare", cloudflareConfig());
await vi.waitFor(() => {
expect(processKillSpy).toHaveBeenCalledWith(expect.any(Number), "SIGTERM");
});
const cloudflareChild = [...children.values()].at(-1);
cloudflareChild?.emitStdout("Connected https://demo.trycloudflare.com");
await switchPromise;
expect(manager.getStatus()).toMatchObject({
state: "running",
provider: "cloudflare",
url: "https://demo.trycloudflare.com",
});
expect(order.indexOf("state:stopping")).toBeLessThan(order.lastIndexOf("spawn"));
});
it("switchProvider failure is rollback-safe and never leaks raw token values", async () => {
const manager = new TunnelProcessManager({
spawnImpl: vi
.fn()
@@ -156,12 +234,14 @@ describe("TunnelProcessManager", () => {
return child as never;
})
.mockImplementationOnce(() => {
throw new Error("cloudflare launcher boom");
throw new Error("cloudflare launcher boom token=secret-token");
}),
});
const states: string[] = [];
const logs: string[] = [];
manager.subscribeStatus((snapshot) => states.push(snapshot.state));
manager.subscribeLogs((entry) => logs.push(entry.message));
await manager.start("tailscale", {
provider: "tailscale",
@@ -182,5 +262,8 @@ describe("TunnelProcessManager", () => {
expect(finalStatus.provider).toBe("cloudflare");
expect(states).toContain("stopping");
expect(states).toContain("stopped");
const logText = logs.join("\n");
expect(logText).not.toContain("secret-token");
});
});

View File

@@ -171,6 +171,7 @@ export class TunnelProcessManager extends EventEmitter implements TunnelManager
await this.startInternal(target, config);
} catch (error) {
const stateError = toStateError("switch_failed", error);
const redactedMessage = this.redactForProviderConfig(target, config, stateError.message);
this.updateStatus({
provider: target,
state: "failed",
@@ -178,14 +179,27 @@ export class TunnelProcessManager extends EventEmitter implements TunnelManager
startedAt: null,
stoppedAt: nowIso(),
url: null,
lastError: stateError,
lastError: {
...stateError,
message: redactedMessage,
},
});
this.emitLog("error", "manager", `Provider switch failed (${previousProvider ?? "none"} -> ${target}): ${stateError.message}`);
this.emitLog("error", "manager", `Provider switch failed (${previousProvider ?? "none"} -> ${target}): ${redactedMessage}`);
throw error;
}
});
}
private redactForProviderConfig(provider: TunnelProvider, config: TunnelProviderConfig, message: string): string {
try {
const adapter = getTunnelProviderAdapter(provider);
const command = adapter.buildCommand(config);
return redactTunnelText(message, command.sensitiveValues);
} catch {
return message;
}
}
private async runExclusive(operation: () => Promise<void>): Promise<void> {
const next = this.operationChain.then(operation);
this.operationChain = next.catch(() => undefined);