diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index 3399aad58..ae0e2c476 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -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 }); diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx b/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx index 52a46e064..b538a41f2 100644 --- a/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx +++ b/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx @@ -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()); diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index 6c9021ffa..291b332be 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -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).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)); diff --git a/packages/core/src/__tests__/store.test.ts b/packages/core/src/__tests__/store.test.ts index a8f60281b..576bc5300 100644 --- a/packages/core/src/__tests__/store.test.ts +++ b/packages/core/src/__tests__/store.test.ts @@ -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).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 }); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index fcb757b75..dba5c666b 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -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); + }); + }); }); }); diff --git a/packages/dashboard/src/__tests__/auth-middleware.test.ts b/packages/dashboard/src/__tests__/auth-middleware.test.ts index f888ca309..475d77714 100644 --- a/packages/dashboard/src/__tests__/auth-middleware.test.ts +++ b/packages/dashboard/src/__tests__/auth-middleware.test.ts @@ -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", () => { diff --git a/packages/dashboard/src/__tests__/remote-auth.test.ts b/packages/dashboard/src/__tests__/remote-auth.test.ts index a67e0f50f..b7a33a088 100644 --- a/packages/dashboard/src/__tests__/remote-auth.test.ts +++ b/packages/dashboard/src/__tests__/remote-auth.test.ts @@ -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 = }; } +function createMockStore(overrides: Partial = {}): 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).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(); + } + }); +}); diff --git a/packages/dashboard/src/__tests__/routes-remote-access.test.ts b/packages/dashboard/src/__tests__/routes-remote-access.test.ts new file mode 100644 index 000000000..ed79a6aff --- /dev/null +++ b/packages/dashboard/src/__tests__/routes-remote-access.test.ts @@ -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 { + return { + getSettings: vi.fn().mockResolvedValue({ remoteAccess: buildRemoteAccessSettings() }), + updateSettings: vi.fn(async (patch: Record) => 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 } = {}) { + 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(" { 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).loginUrl)).toContain("/remote-login?rt="); + expect(String((headlessLoginUrl.body as Record).loginUrl)).toContain("/remote-login?rt="); + }); }); describe("API Error Handling Middleware", () => { diff --git a/packages/dashboard/src/routes/register-settings-memory-routes.ts b/packages/dashboard/src/routes/register-settings-memory-routes.ts index c5a76709a..e99a83206 100644 --- a/packages/dashboard/src/routes/register-settings-memory-routes.ts +++ b/packages/dashboard/src/routes/register-settings-memory-routes.ts @@ -263,30 +263,113 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin // ── Remote Access Routes ──────────────────────────────────────────── + function toRemoteSettingsPayload(remoteAccess: NonNullable>["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; + 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"); diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index 1d6e30cb5..6fa4df8fe 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -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, diff --git a/packages/engine/src/__tests__/tunnel-process-manager.test.ts b/packages/engine/src/__tests__/tunnel-process-manager.test.ts index f726e04dc..dd6187532 100644 --- a/packages/engine/src/__tests__/tunnel-process-manager.test.ts +++ b/packages/engine/src/__tests__/tunnel-process-manager.test.ts @@ -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"); }); }); diff --git a/packages/engine/src/remote-access/tunnel-process-manager.ts b/packages/engine/src/remote-access/tunnel-process-manager.ts index 91e4f6665..2f9af8fa8 100644 --- a/packages/engine/src/remote-access/tunnel-process-manager.ts +++ b/packages/engine/src/remote-access/tunnel-process-manager.ts @@ -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): Promise { const next = this.operationChain.then(operation); this.operationChain = next.catch(() => undefined);