Enable dev tool Dashboard tab via development-environment probe instead of env var (#1749)

This commit is contained in:
Konsti Wohlwend 2026-07-09 20:23:07 -07:00 committed by GitHub
parent f44d5d9bba
commit d5ed20863a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 342 additions and 38 deletions

View File

@ -120,6 +120,16 @@ const nextConfig = {
},
async headers() {
// The development-environment (RDE) dashboard is embedded as an iframe by the
// dev tool overlay, which runs inside the customer's own app on a *different*
// localhost origin (e.g. http://localhost:3000). A plain X-Frame-Options:
// SAMEORIGIN would block that framing, so RDE builds instead scope framing to
// localhost origins via CSP frame-ancestors. This only applies to the RDE
// build target (build:rde-standalone / dev:rde-production); the hosted and
// self-host Docker builds keep X-Frame-Options: SAMEORIGIN.
const isRdeBuild = process.env.HEXCLAVE_DASHBOARD_BUILD_FOR_RDE === "true";
const allowsFraming = isRdeBuild || resolveHexclaveStackEnvVar("NEXT_PUBLIC_HEXCLAVE_IS_PREVIEW", "NEXT_PUBLIC_STACK_IS_PREVIEW") === "true";
const rdeFrameAncestors = "frame-ancestors 'self' http://localhost:* https://localhost:* http://*.localhost:* https://*.localhost:* http://127.0.0.1:* https://127.0.0.1:* http://[::1]:* https://[::1]:*";
return [
{
source: "/(.*)",
@ -141,7 +151,7 @@ const nextConfig = {
key: "X-Content-Type-Options",
value: "nosniff",
},
...resolveHexclaveStackEnvVar("NEXT_PUBLIC_HEXCLAVE_IS_PREVIEW", "NEXT_PUBLIC_STACK_IS_PREVIEW") === "true" ? [] : [{
...allowsFraming ? [] : [{
key: "X-Frame-Options",
value: "SAMEORIGIN",
}],
@ -149,7 +159,7 @@ const nextConfig = {
key: "Content-Security-Policy",
// Note: *.localhost requires Chrome 117+ and may not work in Firefox
// without network.dns.localDomains configuration. Fine for dev tool purposes.
value: "",
value: isRdeBuild ? rdeFrameAncestors : "",
},
],
},

View File

@ -13,7 +13,7 @@
"bundle-type-definitions": "tsx scripts/bundle-type-definitions.ts",
"bundle-type-definitions:watch": "tsx watch --clear-screen=false scripts/bundle-type-definitions.ts",
"build": "rimraf .next/types .next/dev/types .next-development-environment/types .next-development-environment/dev/types && pnpm run bundle-type-definitions && next build",
"build:rde-standalone": "NEXT_CONFIG_OUTPUT=standalone STACK_NEXT_CONFIG_DISABLE_TYPESCRIPT=true pnpm run build",
"build:rde-standalone": "NEXT_CONFIG_OUTPUT=standalone STACK_NEXT_CONFIG_DISABLE_TYPESCRIPT=true HEXCLAVE_DASHBOARD_BUILD_FOR_RDE=true pnpm run build",
"docker-build": "pnpm run bundle-type-definitions && next build --experimental-build-mode compile",
"analyze-bundle": "next experimental-analyze",
"start": "next start --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}01",

View File

@ -41,6 +41,7 @@ runOrExit("pnpm", ["exec", "next", "build"], {
NEXT_CONFIG_OUTPUT: "standalone",
NODE_ENV: "production",
STACK_NEXT_CONFIG_DISABLE_TYPESCRIPT: "true",
HEXCLAVE_DASHBOARD_BUILD_FOR_RDE: "true",
},
});
@ -56,6 +57,12 @@ const server = spawn(process.execPath, [standaloneServerPath], {
env: {
...process.env,
NODE_ENV: "production",
// `next.config`'s `headers()` is evaluated at build time and baked into the
// routes manifest (the build above already sets this flag), so the RDE
// frame-ancestors headers are correct regardless of the server's runtime
// env. We still propagate the flag here so the running process self-reports
// as an RDE build and stays correct if any header logic ever moves to runtime.
HEXCLAVE_DASHBOARD_BUILD_FOR_RDE: "true",
},
stdio: "inherit",
});

View File

@ -0,0 +1,129 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { NextRequest } from "next/server";
vi.mock("server-only", () => ({}));
let tempDir: string | undefined;
const remoteDevelopmentEnvironmentEnabledEnv = "NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT";
const knownProjectId = "known-project-id";
const knownConfigPath = "/home/dev/app/hexclave.config.ts";
function useTempStateFile() {
tempDir = mkdtempSync(join(tmpdir(), "stack-rde-availability-"));
process.env[remoteDevelopmentEnvironmentEnabledEnv] = "true";
process.env.STACK_DEV_ENVS_PATH = join(tempDir, "dev-envs.json");
writeFileSync(process.env.STACK_DEV_ENVS_PATH, JSON.stringify({
version: 1,
localDashboardsByPort: {
"26700": {
port: 26700,
secret: "secret",
pid: 123,
startedAtMillis: Date.now(),
},
},
projectsByConfigPath: {
[knownConfigPath]: {
projectId: knownProjectId,
teamId: "team-id",
publishableClientKey: "pck",
secretServerKey: "ssk",
apiBaseUrl: "http://localhost:8102",
updatedAtMillis: Date.now(),
},
},
}));
chmodSync(process.env.STACK_DEV_ENVS_PATH, 0o600);
}
function request(url: string, headers: Record<string, string>) {
return new NextRequest(url, { headers });
}
async function getResponse(req: NextRequest) {
const { GET } = await import("./route");
return await GET(req);
}
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
delete process.env[remoteDevelopmentEnvironmentEnabledEnv];
delete process.env.STACK_DEV_ENVS_PATH;
if (tempDir != null) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = undefined;
}
});
describe("development environment project-availability route", () => {
it("returns 404 when the development environment is disabled", async () => {
tempDir = mkdtempSync(join(tmpdir(), "stack-rde-availability-"));
process.env.STACK_DEV_ENVS_PATH = join(tempDir, "dev-envs.json");
const response = await getResponse(request(
`http://127.0.0.1:26700/api/development-environment/project-availability?project_id=${knownProjectId}`,
{ host: "127.0.0.1:26700" },
));
expect(response.status).toBe(404);
});
it("returns 403 for non-loopback hosts", async () => {
useTempStateFile();
const response = await getResponse(request(
`http://preview.example.test/api/development-environment/project-availability?project_id=${knownProjectId}`,
{ host: "preview.example.test" },
));
expect(response.status).toBe(403);
});
it("returns 403 for non-localhost origins", async () => {
useTempStateFile();
const response = await getResponse(request(
`http://127.0.0.1:26700/api/development-environment/project-availability?project_id=${knownProjectId}`,
{ host: "127.0.0.1:26700", origin: "https://evil.example.com" },
));
expect(response.status).toBe(403);
});
it("allows requests from a localhost origin (the customer's dev tool)", async () => {
useTempStateFile();
const response = await getResponse(request(
`http://127.0.0.1:26700/api/development-environment/project-availability?project_id=${knownProjectId}`,
{ host: "127.0.0.1:26700", origin: "http://localhost:3000" },
));
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ running: true, project_available: true });
});
it("returns 400 when project_id is missing", async () => {
useTempStateFile();
const response = await getResponse(request(
"http://127.0.0.1:26700/api/development-environment/project-availability",
{ host: "127.0.0.1:26700" },
));
expect(response.status).toBe(400);
});
it("reports project_available: true for a project the development environment owns", async () => {
useTempStateFile();
const response = await getResponse(request(
`http://127.0.0.1:26700/api/development-environment/project-availability?project_id=${knownProjectId}`,
{ host: "127.0.0.1:26700" },
));
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ running: true, project_available: true });
});
it("reports project_available: false for an unknown project", async () => {
useTempStateFile();
const response = await getResponse(request(
"http://127.0.0.1:26700/api/development-environment/project-availability?project_id=some-other-project",
{ host: "127.0.0.1:26700" },
));
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ running: true, project_available: false });
});
});

View File

@ -0,0 +1,58 @@
import { getPublicEnvVar } from "@/lib/env";
import { isLocalhost } from "@hexclave/shared/dist/utils/urls";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
// The dev tool overlay runs inside the customer's own app, which lives on a
// different localhost origin than the development-environment dashboard on
// :26700. It therefore has no browser secret and must reach this endpoint
// cross-origin. All this endpoint ever reveals is whether the locally-running
// development environment already owns a given project id — never secrets,
// config, or any other project data. Even so, the dashboard's global CORS
// middleware sets `Access-Control-Allow-Origin: *` on every /api/ route, which
// would otherwise let any website a developer visits read that ownership
// boolean off their machine. We therefore gate on both:
// - the request Host being loopback (defense-in-depth against proxied hosts;
// the development environment only ever binds to loopback anyway), and
// - the request Origin being absent or itself a localhost origin, so a
// non-localhost page cannot read the response cross-origin.
function requestHostIsLoopback(req: NextRequest): boolean {
const host = req.headers.get("host");
if (host == null) return false;
return isLocalhost(`http://${host}`);
}
// A browser attaches an `Origin` header to every cross-origin (and most
// same-origin) fetches. A missing Origin means the caller is not a browser
// cross-origin request (e.g. a same-origin/non-browser client), which is fine.
// If it is present, it must be a localhost origin — otherwise some remote site
// is trying to read our loopback-only ownership boolean.
function requestOriginIsAllowed(req: NextRequest): boolean {
const origin = req.headers.get("origin");
if (origin == null) return true;
return isLocalhost(origin);
}
export async function GET(req: NextRequest): Promise<NextResponse> {
const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true";
if (!isRemoteDevelopmentEnvironment) {
return NextResponse.json({ error: "This endpoint is only available in development environments." }, { status: 404 });
}
if (!requestHostIsLoopback(req)) {
return NextResponse.json({ error: "This endpoint is only available on loopback addresses." }, { status: 403 });
}
if (!requestOriginIsAllowed(req)) {
return NextResponse.json({ error: "This endpoint is only available to localhost origins." }, { status: 403 });
}
const projectId = req.nextUrl.searchParams.get("project_id");
if (projectId == null || projectId.length === 0) {
return NextResponse.json({ error: "Missing project_id query parameter." }, { status: 400 });
}
const { getRemoteDevelopmentEnvironmentProjectConfigPaths } = await import("@/lib/remote-development-environment/manager");
const projectAvailable = getRemoteDevelopmentEnvironmentProjectConfigPaths().has(projectId);
return NextResponse.json({ running: true, project_available: projectAvailable });
}

View File

@ -5,7 +5,6 @@ import { DEV_TOOL_ROOT_ID } from "@hexclave/shared/dist/utils/dev-tool";
import { runAsynchronously, runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
import { isLocalhost } from "@hexclave/shared/dist/utils/urls";
import type { StackClientApp } from "../lib/hexclave-app";
import { envVars } from "../generated/env";
import { getGlobalUiInstance, h, hasAppendChild, setGlobalUiInstance, setHtml, type UiGlobalInstance } from "../in-page-ui/dom";
import { getBaseUrl } from "../lib/hexclave-app/apps/implementations/common";
import type { HandlerUrlOptions, HandlerUrls, HandlerUrlTarget } from "../lib/hexclave-app/common";
@ -60,6 +59,11 @@ const MAX_LOG_ENTRIES = 500;
const CONSOLE_LOG_BATCH_SIZE = 100;
const DRAG_THRESHOLD = 5;
const DOCS_URL = 'https://docs.hexclave.com';
// The local development-environment dashboard always listens on this loopback
// port (see the CLI's DEFAULT_DASHBOARD_PORT). The dev tool runs inside the
// customer's own app, so it can't read the CLI's env vars — the port is fixed.
const LOCAL_DASHBOARD_ORIGIN = 'http://localhost:26700';
const HOSTED_DASHBOARD_URL = 'https://app.hexclave.com';
const TABS: { id: TabId; label: string; icon: string }[] = [
{ id: 'overview', label: 'Overview', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>' },
@ -190,42 +194,60 @@ function resolveApiBaseUrl(app: StackClientApp<true>): string {
return getBaseUrl(opts.baseUrl);
}
function shouldShowDashboardTab(app: StackClientApp<true>): boolean {
return envVars.HEXCLAVE_IS_LOCAL_EMULATOR === "true" && isLocalhost(resolveApiBaseUrl(app));
function localDashboardProjectUrl(app: StackClientApp<true>): string {
return `${LOCAL_DASHBOARD_ORIGIN}/projects/${encodeURIComponent(app.projectId)}`;
}
function getTabsForApp(app: StackClientApp<true>): { id: TabId; label: string; icon: string }[] {
if (shouldShowDashboardTab(app)) {
return TABS;
}
return TABS.filter((tab) => tab.id !== 'dashboard');
function parseProjectAvailabilityResponse(body: unknown): boolean {
if (typeof body !== 'object' || body === null) return false;
return 'project_available' in body && body.project_available === true;
}
function deriveDashboardBaseUrl(apiBaseUrl: string): string {
// How long to wait for the availability probe before giving up. A refused
// connection rejects almost immediately, but a host that accepts the socket yet
// never responds would otherwise leave the tab stuck on "Checking…" forever.
const LOCAL_DASHBOARD_PROBE_TIMEOUT_MS = 3000;
// Whether the current page can even talk to the HTTP-only local dashboard.
// LOCAL_DASHBOARD_ORIGIN is `http://localhost:26700`, so the page itself must be
// an http: localhost origin: an https: page (e.g. an HTTPS dev server) would
// have both the probe fetch and the iframe embed blocked as mixed content, so
// we treat that as "not available" and fall back to the hosted dashboard.
function canReachLocalDashboard(): boolean {
return isLocalhost(window.location.href) && window.location.protocol === 'http:';
}
// Decides whether the Dashboard tab can embed the locally-running
// development-environment dashboard. Rather than inferring from build-time env
// vars (which don't reflect whether the dev environment is actually up right
// now), we ask the running development environment directly:
// 1. We must be viewing the app from an http: localhost origin — the HTTP-only
// dashboard on :26700 is only reachable from, and only relevant to, a local
// browser (and an https: page can't reach it without mixed-content errors).
// 2. The development environment must be running AND already own this exact
// project (see the project-availability route it answers).
// Any failure — dev environment not running (connection refused), probe timeout,
// project unknown, or a malformed response — resolves to `false`, which shows
// the hosted-dashboard hint instead.
async function isLocalDashboardProjectAvailable(app: StackClientApp<true>): Promise<boolean> {
if (!canReachLocalDashboard()) return false;
const url = `${LOCAL_DASHBOARD_ORIGIN}/api/development-environment/project-availability?project_id=${encodeURIComponent(app.projectId)}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), LOCAL_DASHBOARD_PROBE_TIMEOUT_MS);
try {
const url = new URL(apiBaseUrl);
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]') {
const port = url.port;
if (port && port.endsWith('02')) {
url.port = port.slice(0, -2) + '01';
}
return url.origin;
}
if (url.hostname.startsWith('api.')) {
url.hostname = 'app.' + url.hostname.slice(4);
return url.origin;
}
return url.origin;
const response = await fetch(url, { method: 'GET', signal: controller.signal });
if (!response.ok) return false;
return parseProjectAvailabilityResponse(await response.json());
} catch {
return 'https://app.hexclave.com';
// Reachability probe: when the local development environment isn't running,
// the fetch rejects (connection refused); when it hangs, the AbortController
// aborts it. Either way that simply means "not available".
return false;
} finally {
clearTimeout(timeout);
}
}
function resolveDashboardUrl(app: StackClientApp<true>): string {
const base = deriveDashboardBaseUrl(resolveApiBaseUrl(app));
return `${base}/projects/${encodeURIComponent(app.projectId)}`;
}
function formatTimestamp(ts: number): string {
return new Date(ts).toLocaleTimeString([], {
hour: '2-digit',
@ -560,8 +582,8 @@ function createTabBar(
// Iframe helper
// ---------------------------------------------------------------------------
function createIframeTab(src: string, title: string, loadingMsg = 'Loading\u2026', errorMsg = 'Unable to load content', errorDetail?: string, openExternallyLabel?: string): HTMLElement {
const container = h('div', { className: 'sdt-iframe-container' });
function populateIframeTab(container: HTMLElement, src: string, title: string, loadingMsg = 'Loading\u2026', errorMsg = 'Unable to load content', errorDetail?: string, openExternallyLabel?: string): void {
container.innerHTML = '';
if (openExternallyLabel != null) {
container.appendChild(h('div', { className: 'sdt-iframe-toolbar' },
h('a', { href: src, target: '_blank', rel: 'noopener noreferrer', className: 'sdt-iframe-open-link' }, openExternallyLabel),
@ -591,7 +613,7 @@ function createIframeTab(src: string, title: string, loadingMsg = 'Loading\u2026
}
const retryBtn = h('button', { className: 'sdt-iframe-error-btn' }, 'Retry');
retryBtn.addEventListener('click', () => {
container.replaceWith(createIframeTab(src, title, loadingMsg, errorMsg, errorDetail, openExternallyLabel));
populateIframeTab(container, src, title, loadingMsg, errorMsg, errorDetail, openExternallyLabel);
});
errDiv.appendChild(retryBtn);
const link = h('a', { href: src, target: '_blank', rel: 'noopener noreferrer', style: { color: 'var(--sdt-accent)', fontSize: '12px', textDecoration: 'none' } }, 'Open in new tab');
@ -600,7 +622,6 @@ function createIframeTab(src: string, title: string, loadingMsg = 'Loading\u2026
});
container.appendChild(iframe);
return container;
}
// ===========================================================================================
@ -1678,8 +1699,52 @@ function createAITab(app: StackClientApp<true>): HTMLElement {
// ---------------------------------------------------------------------------
function createDashboardTab(app: StackClientApp<true>): HTMLElement {
const dashboardUrl = resolveDashboardUrl(app);
return createIframeTab(dashboardUrl, 'Hexclave Dashboard', 'Loading dashboard\u2026', 'Unable to load dashboard', 'The dashboard may require authentication or block framing', 'Open in New Tab');
const container = h('div', { className: 'sdt-iframe-container' });
function showChecking() {
container.innerHTML = '';
container.appendChild(h('div', { className: 'sdt-iframe-loading' }, 'Checking development environment\u2026'));
}
function showUnavailable() {
container.innerHTML = '';
const message = h('div', { className: 'sdt-dashboard-unavailable' });
const text = h('div', { className: 'sdt-dashboard-unavailable-text' });
text.appendChild(document.createTextNode('Navigate to '));
text.appendChild(h('a', {
className: 'sdt-dashboard-unavailable-link',
href: HOSTED_DASHBOARD_URL,
target: '_blank',
rel: 'noopener noreferrer',
}, HOSTED_DASHBOARD_URL));
text.appendChild(document.createTextNode(" to view this project's dashboard"));
message.appendChild(text);
container.appendChild(message);
}
async function check() {
showChecking();
const available = await isLocalDashboardProjectAvailable(app);
// The dev tool may have been torn down while we were probing; only mutate
// the DOM if this container is still mounted.
if (!container.isConnected) return;
if (available) {
populateIframeTab(
container,
localDashboardProjectUrl(app),
'Hexclave Dashboard',
'Loading dashboard\u2026',
'Unable to load dashboard',
'The dashboard may require authentication or block framing',
'Open in New Tab',
);
} else {
showUnavailable();
}
}
runAsynchronously(check);
return container;
}
// ---------------------------------------------------------------------------
@ -2109,7 +2174,7 @@ function createPanel(
panel.style.height = state.get().panelHeight + 'px';
}
const tabs = getTabsForApp(app);
const tabs = TABS;
const storedActiveTab = state.get().activeTab;
const activeTab = tabs.some((tab) => tab.id === storedActiveTab) ? storedActiveTab : DEFAULT_STATE.activeTab;

View File

@ -324,6 +324,14 @@ export const devToolCSS = getInPageUiBaseCSS('.hexclave-devtool') + `
z-index: 1;
}
/* Iframe panes lay their content out as a flex column so the embedded
dashboard (or the fallback message) fills the pane height. Uses two
classes to win over the plain .sdt-tab-pane-active { display: block }. */
.hexclave-devtool .sdt-tab-pane-iframe.sdt-tab-pane-active {
display: flex;
flex-direction: column;
}
/* ===== Overview tab — single column ===== */
.hexclave-devtool .sdt-ov {
@ -1231,6 +1239,33 @@ export const devToolCSS = getInPageUiBaseCSS('.hexclave-devtool') + `
background: var(--sdt-accent-hover);
}
.hexclave-devtool .sdt-dashboard-unavailable {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 24px;
text-align: center;
color: var(--sdt-text-secondary);
font-size: 14px;
}
.hexclave-devtool .sdt-dashboard-unavailable-text {
max-width: 420px;
line-height: 1.5;
}
.hexclave-devtool .sdt-dashboard-unavailable-link {
color: var(--sdt-accent);
text-decoration: none;
}
.hexclave-devtool .sdt-dashboard-unavailable-link:hover {
text-decoration: underline;
}
/* Shared content fade animation */
.hexclave-devtool .sdt-tab-content-fade {
animation: sdt-tab-fade-in 0.15s ease-out;