prism-mcp-server 20.21.7 → 20.21.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,12 +58,14 @@ A paid subscription adds cloud sync, higher model tiers, and team features throu
58
58
  ```bash
59
59
  npm install -g prism-mcp-server
60
60
  prism connect
61
+ prism dashboard
61
62
  ```
62
63
 
63
64
  Use `prism connect --dry-run` to preview changes, `prism connect --all` to
64
65
  configure every detected host, or `prism connect --refresh` to reconcile
65
66
  Prism-managed entries after an upgrade. Restart the host after connecting.
66
67
 
68
+ `prism dashboard` opens the current local Mind Palace without a Synalux login.
67
69
  Prism works locally without an account, API key, or cloud subscription. Add a
68
70
  Synalux subscription when you want cloud memory, paid-tier skills, or team
69
71
  features.
@@ -149,6 +151,49 @@ or by re-enabling after each run.
149
151
  <details>
150
152
  <summary>Release history (optional)</summary>
151
153
 
154
+ ## What's New in v20.21.10
155
+
156
+ ### Signed-out Free dashboards keep working locally
157
+
158
+ - Signing out now switches Project View, project details, and graph reads to
159
+ local SQLite without requiring an account, configuration edit, or restart.
160
+ - The saved Synalux backend preference remains ready for the next sign-in;
161
+ accidental missing cloud credentials still fail closed.
162
+ - Latest Activity uses the newest durable session when it is newer than the
163
+ saved handoff. Recent Sessions are newest first, and saved handoff versions
164
+ remain available as restore points.
165
+
166
+ ## What's New in v20.21.9
167
+
168
+ ### Prism Free opens without a Synalux account
169
+
170
+ - Run `prism dashboard` to open the current local Mind Palace. No sign-in,
171
+ account-link code, API key, paid plan, or Free-plan redemption is required.
172
+ - A bare local dashboard address now gives the exact local-open command instead
173
+ of rendering a broken account state.
174
+ - Account & Settings shows Prism Free as active. Synalux linking remains an
175
+ optional action for cloud sync, billing, and paid features.
176
+ - Local access stays protected without printing its browser capability into
177
+ startup output, and unsafe state paths or redirected identity probes fail
178
+ closed.
179
+
180
+ ## What's New in v20.21.8
181
+
182
+ ### Dashboard graphs use the authenticated Portal
183
+
184
+ - The selected cloud project now shows its real session nodes and stored links
185
+ instead of collapsing to a keyword-only project bubble.
186
+ - **Synthesize Edges** uses the authenticated Synalux Portal embedding contract;
187
+ the Prism dashboard process does not need a Google or Gemini API key.
188
+ - Existing vectors and links are reused on repeat runs, synthesis fan-out is
189
+ bounded, and Portal failures are shown rather than rendered as an empty graph.
190
+ - **Account & Settings** now shows the current user, role, and Free, Standard,
191
+ Advanced, or Enterprise plan, with visible sign-in, upgrade, billing, and
192
+ sign-out actions.
193
+ - A selected paid plan survives the Portal sign-in round trip, live and test
194
+ Stripe catalogs resolve every paid tier, and localized checkout never reuses
195
+ a USD Price for another currency.
196
+
152
197
  ## What's New in v20.18.1
153
198
 
154
199
  ### Task skills survive compaction and update in place
@@ -912,8 +957,9 @@ Every conversation feeds a persistent store. The next session loads the right co
912
957
 
913
958
  The dashboard shows your current project state, pending TODOs, intent health, and a neural knowledge graph — all built automatically from your agent sessions.
914
959
 
915
- It runs on loopback and is gated by a per-startup token by default — open the
916
- tokenized URL printed in the startup log (`http://localhost:3000/?token=…`).
960
+ It runs on loopback and is gated by a local token by default — run
961
+ `prism dashboard` to open it without signing in. This local browser step is not
962
+ a Free-plan redemption.
917
963
  Requests with an untrusted `Host`/`Origin` are refused, closing the DNS-rebinding
918
964
  exposure fixed in GHSA-9cvx-7x8q-3g6m. See [docs/IDE_SETUP.md](docs/IDE_SETUP.md#securing-the-dashboard)
919
965
  to pin the token, disable it, or configure Basic Auth / JWKS.
package/dist/cli.js CHANGED
@@ -15,6 +15,7 @@ import { runBrowserCli } from './browserCli.js';
15
15
  import { filterPrismMemoryContext } from './utils/memoryQuality.js';
16
16
  import { isRecoverableStartupStorageError } from './utils/startupRecovery.js';
17
17
  import { verifyBehaviorHandler } from './tools/behavioralVerifierHandler.js';
18
+ import { isLocalDashboardRunning, openDashboardUrl, readDashboardAccessState, } from './dashboard/dashboardAccess.js';
18
19
  const program = new Command();
19
20
  /** Build the stable `prism load --json` envelope from depth-specific context. */
20
21
  export function buildLoadJsonOutput(project, data, level, metadata) {
@@ -114,6 +115,28 @@ program
114
115
  .command('bootstrap')
115
116
  .description('Print the canonical dashboard-configured first-turn Prism greeting')
116
117
  .action(runBootstrapCommand);
118
+ program
119
+ .command('dashboard')
120
+ .description('Open the current local Mind Palace dashboard (no Synalux account required)')
121
+ .option('--print', 'Print the current local dashboard link instead of opening a browser')
122
+ .action(async (options) => {
123
+ try {
124
+ const { url, probeKey } = readDashboardAccessState();
125
+ if (!(await isLocalDashboardRunning(url, probeKey))) {
126
+ throw new Error('The recorded Prism dashboard is not running. Restart your connected MCP host first.');
127
+ }
128
+ if (options.print) {
129
+ console.log(url);
130
+ return;
131
+ }
132
+ openDashboardUrl(url);
133
+ console.log('Opened the local Prism Free dashboard. No Synalux account is required.');
134
+ }
135
+ catch (error) {
136
+ console.error(`Dashboard unavailable: ${error instanceof Error ? error.message : String(error)}`);
137
+ process.exitCode = 1;
138
+ }
139
+ });
117
140
  /**
118
141
  * One-shot fallback for hosts whose long-lived MCP transport has closed.
119
142
  * This calls the canonical handler, including its authenticated portal path
@@ -0,0 +1,328 @@
1
+ import { invalidateEntitlements } from "../utils/entitlements.js";
2
+ import { getSynaluxJwt, invalidateSynaluxJwt } from "../utils/synaluxJwt.js";
3
+ import { resolvePortalBaseUrl, usablePortalKey } from "../utils/synaluxSearch.js";
4
+ import { isSynaluxSignedOut, setSynaluxSignedOut } from "../utils/synaluxCredentialState.js";
5
+ import { upgradeInsecureCloudUrl } from "../utils/secureUrl.js";
6
+ const DEFAULT_PORTAL_URL = "https://synalux.ai";
7
+ const MAX_BODY_BYTES = 16 * 1024;
8
+ const VALID_PLANS = new Set(["free", "standard", "advanced", "enterprise"]);
9
+ let accountMutationQueue = Promise.resolve();
10
+ async function serializeAccountMutation(operation) {
11
+ const previous = accountMutationQueue;
12
+ let release;
13
+ accountMutationQueue = new Promise((resolve) => { release = resolve; });
14
+ await previous.catch(() => { });
15
+ try {
16
+ return await operation();
17
+ }
18
+ finally {
19
+ release();
20
+ }
21
+ }
22
+ const defaultDeps = {
23
+ fetcher: fetch,
24
+ getSetting: async (key, defaultValue) => (await import("../storage/configStorage.js")).getSetting(key, defaultValue),
25
+ setSetting: async (key, value) => (await import("../storage/configStorage.js")).setSetting(key, value),
26
+ getJwt: getSynaluxJwt,
27
+ invalidateJwt: invalidateSynaluxJwt,
28
+ invalidateEntitlements,
29
+ closeStorage: async () => (await import("../storage/index.js")).closeStorage(),
30
+ resolvePortalBaseUrl,
31
+ usablePortalKey,
32
+ };
33
+ function json(res, status, body) {
34
+ res.writeHead(status, {
35
+ "Content-Type": "application/json",
36
+ "Cache-Control": "private, no-store",
37
+ });
38
+ res.end(JSON.stringify(body));
39
+ }
40
+ async function readJson(req) {
41
+ const chunks = [];
42
+ let total = 0;
43
+ for await (const chunk of req) {
44
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
45
+ total += buffer.length;
46
+ if (total > MAX_BODY_BYTES)
47
+ throw new Error("Request body too large");
48
+ chunks.push(buffer);
49
+ }
50
+ const raw = Buffer.concat(chunks).toString("utf8");
51
+ if (!raw)
52
+ return {};
53
+ const value = JSON.parse(raw);
54
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
55
+ throw new Error("Body must be a JSON object");
56
+ }
57
+ return value;
58
+ }
59
+ function safePortalOrigin(raw) {
60
+ if (!raw)
61
+ return null;
62
+ try {
63
+ const upgraded = upgradeInsecureCloudUrl(raw.trim());
64
+ const parsed = new URL(upgraded);
65
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(parsed.hostname))) {
66
+ return null;
67
+ }
68
+ return parsed.origin;
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ async function portalOrigin(deps) {
75
+ return safePortalOrigin(deps.resolvePortalBaseUrl())
76
+ ?? safePortalOrigin(await deps.getSetting("PRISM_SYNALUX_BASE_URL", ""))
77
+ ?? safePortalOrigin(await deps.getSetting("SYNALUX_BASE_URL", ""))
78
+ ?? DEFAULT_PORTAL_URL;
79
+ }
80
+ async function applyStoredSignOut(deps) {
81
+ const signedOut = (await deps.getSetting("PRISM_SYNALUX_SIGNED_OUT", "")) === "true";
82
+ setSynaluxSignedOut(signedOut);
83
+ if (signedOut)
84
+ delete process.env.PRISM_SYNALUX_API_KEY;
85
+ return signedOut;
86
+ }
87
+ function authUrl(origin) {
88
+ return `${origin}/auth?source=prism`;
89
+ }
90
+ function signedOutAccount(origin) {
91
+ return {
92
+ signed_in: false,
93
+ configured: false,
94
+ name: null,
95
+ role_key: null,
96
+ plan: "free",
97
+ billing: { action: "upgrade", url: `${origin}/pricing` },
98
+ auth_url: authUrl(origin),
99
+ };
100
+ }
101
+ function normalizeAccount(value, origin) {
102
+ if (!value || typeof value !== "object")
103
+ throw new Error("Portal returned an invalid account response");
104
+ const raw = value;
105
+ const plan = typeof raw.plan === "string" && VALID_PLANS.has(raw.plan) ? raw.plan : "free";
106
+ const billingRaw = raw.billing && typeof raw.billing === "object"
107
+ ? raw.billing
108
+ : {};
109
+ const action = billingRaw.action === "manage" || billingRaw.action === "included"
110
+ ? billingRaw.action
111
+ : "upgrade";
112
+ const url = typeof billingRaw.url === "string" && isAllowedBillingUrl(billingRaw.url, origin)
113
+ ? billingRaw.url
114
+ : null;
115
+ return {
116
+ signed_in: raw.signed_in === true,
117
+ configured: true,
118
+ name: typeof raw.name === "string" ? raw.name.slice(0, 160) : null,
119
+ role_key: typeof raw.role_key === "string" ? raw.role_key.slice(0, 80) : null,
120
+ plan: plan,
121
+ plan_source: raw.plan_source === "managed" ? "managed" : "stripe",
122
+ billing: { action, url },
123
+ auth_url: authUrl(origin),
124
+ };
125
+ }
126
+ function isAllowedBillingUrl(raw, origin) {
127
+ try {
128
+ const url = new URL(raw);
129
+ return url.origin === origin
130
+ || (url.protocol === "https:" && url.hostname === "billing.stripe.com");
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ }
136
+ async function accountWithJwt(deps, origin) {
137
+ let jwt = await deps.getJwt();
138
+ if (!jwt || isSynaluxSignedOut())
139
+ throw new Error("Synalux sign-in expired. Sign in again.");
140
+ const send = (token) => deps.fetcher(`${origin}/api/v1/prism/account`, {
141
+ method: "GET",
142
+ headers: { Authorization: `Bearer ${token}`, "X-Prism-Client": "prism-dashboard" },
143
+ signal: AbortSignal.timeout(10_000),
144
+ redirect: "error",
145
+ });
146
+ let response = await send(jwt);
147
+ if (isSynaluxSignedOut())
148
+ throw new Error("Synalux account is signed out");
149
+ if (response.status === 401) {
150
+ deps.invalidateJwt();
151
+ jwt = await deps.getJwt();
152
+ if (!jwt)
153
+ throw new Error("Synalux sign-in expired. Sign in again.");
154
+ response = await send(jwt);
155
+ if (isSynaluxSignedOut())
156
+ throw new Error("Synalux account is signed out");
157
+ }
158
+ if (!response.ok)
159
+ throw new Error(`Unable to load Synalux account (HTTP ${response.status})`);
160
+ const data = await response.json();
161
+ if (isSynaluxSignedOut())
162
+ throw new Error("Synalux account is signed out");
163
+ return normalizeAccount(data, origin);
164
+ }
165
+ export async function loadDashboardAccount(overrides = {}) {
166
+ const deps = { ...defaultDeps, ...overrides };
167
+ const origin = await portalOrigin(deps);
168
+ if (await applyStoredSignOut(deps))
169
+ return signedOutAccount(origin);
170
+ if (!deps.usablePortalKey())
171
+ return signedOutAccount(origin);
172
+ return accountWithJwt(deps, origin);
173
+ }
174
+ async function revokeRawToken(deps, origin, token) {
175
+ try {
176
+ const response = await deps.fetcher(`${origin}/api/v1/prism/account`, {
177
+ method: "DELETE",
178
+ headers: { Authorization: `Bearer ${token}`, "X-Prism-Client": "prism-dashboard" },
179
+ signal: AbortSignal.timeout(10_000),
180
+ redirect: "error",
181
+ });
182
+ return response.ok || response.status === 401;
183
+ }
184
+ catch {
185
+ return false;
186
+ }
187
+ }
188
+ export async function connectDashboardAccount(code, overrides = {}) {
189
+ return serializeAccountMutation(async () => {
190
+ const deps = { ...defaultDeps, ...overrides };
191
+ if (typeof code !== "string" || !code.startsWith("synalux_code_") || code.length > 256) {
192
+ throw new Error("Enter a valid one-time Prism sign-in code");
193
+ }
194
+ const origin = await portalOrigin(deps);
195
+ const response = await deps.fetcher(`${origin}/api/v1/auth/code-exchange`, {
196
+ method: "POST",
197
+ headers: { "Content-Type": "application/json", "X-Prism-Client": "prism-dashboard" },
198
+ body: JSON.stringify({ code, client: "prism" }),
199
+ signal: AbortSignal.timeout(10_000),
200
+ redirect: "error",
201
+ });
202
+ const data = await response.json().catch(() => ({}));
203
+ if (!response.ok)
204
+ throw new Error(typeof data.error === "string" ? data.error : "Prism sign-in failed");
205
+ const token = typeof data.api_token === "string" ? data.api_token : "";
206
+ if (!token.startsWith("synalux_sk_") || token.length > 512) {
207
+ throw new Error("Portal returned an invalid Prism credential");
208
+ }
209
+ try {
210
+ await deps.setSetting("PRISM_SYNALUX_BASE_URL", origin);
211
+ await deps.setSetting("PRISM_SYNALUX_API_KEY", token);
212
+ await deps.setSetting("PRISM_SYNALUX_SIGNED_OUT", "false");
213
+ }
214
+ catch (error) {
215
+ await revokeRawToken(deps, origin, token);
216
+ await deps.setSetting("PRISM_SYNALUX_SIGNED_OUT", "true").catch(() => { });
217
+ await deps.setSetting("PRISM_SYNALUX_API_KEY", "").catch(() => { });
218
+ throw new Error(`Could not save Prism sign-in: ${error instanceof Error ? error.message : String(error)}`);
219
+ }
220
+ setSynaluxSignedOut(false);
221
+ process.env.PRISM_SYNALUX_BASE_URL = origin;
222
+ process.env.PRISM_SYNALUX_API_KEY = token;
223
+ deps.invalidateJwt();
224
+ deps.invalidateEntitlements();
225
+ await deps.closeStorage();
226
+ return accountWithJwt(deps, origin);
227
+ });
228
+ }
229
+ export async function openDashboardBilling(overrides = {}) {
230
+ const deps = { ...defaultDeps, ...overrides };
231
+ const origin = await portalOrigin(deps);
232
+ if (await applyStoredSignOut(deps) || !deps.usablePortalKey()) {
233
+ return { url: `${origin}/pricing`, action: "upgrade" };
234
+ }
235
+ let jwt = await deps.getJwt();
236
+ if (!jwt || isSynaluxSignedOut())
237
+ throw new Error("Synalux sign-in expired. Sign in again.");
238
+ const send = (token) => deps.fetcher(`${origin}/api/v1/prism/account`, {
239
+ method: "POST",
240
+ headers: { Authorization: `Bearer ${token}`, "X-Prism-Client": "prism-dashboard" },
241
+ signal: AbortSignal.timeout(10_000),
242
+ redirect: "error",
243
+ });
244
+ let response = await send(jwt);
245
+ if (isSynaluxSignedOut())
246
+ throw new Error("Synalux account is signed out");
247
+ if (response.status === 401) {
248
+ deps.invalidateJwt();
249
+ jwt = await deps.getJwt();
250
+ if (!jwt)
251
+ throw new Error("Synalux sign-in expired. Sign in again.");
252
+ response = await send(jwt);
253
+ if (isSynaluxSignedOut())
254
+ throw new Error("Synalux account is signed out");
255
+ }
256
+ const data = await response.json().catch(() => ({}));
257
+ if (isSynaluxSignedOut())
258
+ throw new Error("Synalux account is signed out");
259
+ if (!response.ok)
260
+ throw new Error(typeof data.error === "string" ? data.error : `Billing unavailable (HTTP ${response.status})`);
261
+ if (typeof data.url !== "string" || !isAllowedBillingUrl(data.url, origin)) {
262
+ throw new Error("Portal returned an unsafe billing URL");
263
+ }
264
+ return { url: data.url, action: typeof data.action === "string" ? data.action : "manage" };
265
+ }
266
+ export async function signOutDashboardAccount(overrides = {}) {
267
+ return serializeAccountMutation(async () => {
268
+ const deps = { ...defaultDeps, ...overrides };
269
+ const origin = await portalOrigin(deps);
270
+ const token = deps.usablePortalKey() ?? (await deps.getSetting("PRISM_SYNALUX_API_KEY", "")).trim();
271
+ // Cut off every local credential path before waiting on the network. The
272
+ // storage singleton captures its refresh token at construction, so clearing
273
+ // only process.env would leave an already-created backend signed in.
274
+ setSynaluxSignedOut(true);
275
+ delete process.env.PRISM_SYNALUX_API_KEY;
276
+ deps.invalidateJwt();
277
+ deps.invalidateEntitlements();
278
+ await deps.setSetting("PRISM_SYNALUX_SIGNED_OUT", "true");
279
+ await deps.setSetting("PRISM_SYNALUX_API_KEY", "");
280
+ await deps.closeStorage();
281
+ const revoked = token ? await revokeRawToken(deps, origin, token) : true;
282
+ return { signed_out: true, revoked };
283
+ });
284
+ }
285
+ export async function handleAccountRoutes(url, req, res) {
286
+ if (url.pathname === "/api/account" && req.method === "GET") {
287
+ try {
288
+ json(res, 200, await loadDashboardAccount());
289
+ }
290
+ catch (error) {
291
+ json(res, 502, { error: error instanceof Error ? error.message : "Unable to load account" });
292
+ }
293
+ return true;
294
+ }
295
+ if (url.pathname === "/api/account/connect" && req.method === "POST") {
296
+ try {
297
+ const body = await readJson(req);
298
+ json(res, 200, await connectDashboardAccount(body.code));
299
+ }
300
+ catch (error) {
301
+ json(res, 400, { error: error instanceof Error ? error.message : "Prism sign-in failed" });
302
+ }
303
+ return true;
304
+ }
305
+ if (url.pathname === "/api/account/billing" && req.method === "POST") {
306
+ try {
307
+ json(res, 200, await openDashboardBilling());
308
+ }
309
+ catch (error) {
310
+ json(res, 502, { error: error instanceof Error ? error.message : "Billing unavailable" });
311
+ }
312
+ return true;
313
+ }
314
+ if (url.pathname === "/api/account/signout" && req.method === "POST") {
315
+ try {
316
+ const result = await signOutDashboardAccount();
317
+ json(res, result.revoked ? 200 : 502, {
318
+ ...result,
319
+ ...(result.revoked ? {} : { warning: "Signed out locally, but remote token revocation could not be confirmed" }),
320
+ });
321
+ }
322
+ catch (error) {
323
+ json(res, 500, { error: error instanceof Error ? error.message : "Sign-out failed" });
324
+ }
325
+ return true;
326
+ }
327
+ return false;
328
+ }
@@ -0,0 +1,213 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { chmodSync, closeSync, constants as fsConstants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { randomBytes } from "node:crypto";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { createDashboardProbeRequest, DASHBOARD_PROBE_PATH, generateDashboardProbeKey, validateDashboardProbeKey, verifyDashboardProbeResponse, } from "./dashboardProbe.js";
7
+ const DASHBOARD_URL_FILE = "dashboard.url";
8
+ const MAX_DASHBOARD_URL_BYTES = 4096;
9
+ export function dashboardAccessUrlPath(home = homedir()) {
10
+ return join(home, ".prism-mcp", DASHBOARD_URL_FILE);
11
+ }
12
+ function dashboardAccessDirectory(home) {
13
+ const directory = join(home, ".prism-mcp");
14
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
15
+ const stat = lstatSync(directory);
16
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
17
+ throw new Error("Local dashboard directory is not a regular directory");
18
+ }
19
+ if (process.platform !== "win32")
20
+ chmodSync(directory, 0o700);
21
+ return directory;
22
+ }
23
+ function validateDashboardUrl(raw) {
24
+ const value = raw.trim();
25
+ if (!value || Buffer.byteLength(value, "utf8") > MAX_DASHBOARD_URL_BYTES) {
26
+ throw new Error("Local dashboard link is missing or invalid");
27
+ }
28
+ let parsed;
29
+ try {
30
+ parsed = new URL(value);
31
+ }
32
+ catch {
33
+ throw new Error("Local dashboard link is invalid");
34
+ }
35
+ const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]);
36
+ const queryKeys = [...parsed.searchParams.keys()];
37
+ if (parsed.protocol !== "http:" ||
38
+ !loopbackHosts.has(parsed.hostname) ||
39
+ !/^\d{1,5}$/.test(parsed.port) ||
40
+ Number(parsed.port) < 1 ||
41
+ Number(parsed.port) > 65535 ||
42
+ parsed.username ||
43
+ parsed.password ||
44
+ parsed.pathname !== "/" ||
45
+ parsed.hash ||
46
+ queryKeys.some((key) => key !== "token") ||
47
+ (parsed.searchParams.has("token") && !parsed.searchParams.get("token"))) {
48
+ throw new Error("Local dashboard link is invalid");
49
+ }
50
+ return parsed.toString();
51
+ }
52
+ /**
53
+ * Persist the current local dashboard link for the `prism dashboard` command.
54
+ * The token is a localhost capability, so the file is owner-only and symlinks
55
+ * present at validation time are rejected rather than followed. This is not a
56
+ * boundary against another process running as the same OS user: that process
57
+ * can already read or replace owner-only Prism state.
58
+ */
59
+ export function writeDashboardAccessUrl(url, home = homedir(), probeKey = generateDashboardProbeKey()) {
60
+ const validated = validateDashboardUrl(url);
61
+ const validatedProbeKey = validateDashboardProbeKey(probeKey);
62
+ const directory = dashboardAccessDirectory(home);
63
+ const filePath = dashboardAccessUrlPath(home);
64
+ try {
65
+ const existing = lstatSync(filePath);
66
+ if (!existing.isFile() || existing.isSymbolicLink()) {
67
+ throw new Error("Local dashboard link path is not a regular file");
68
+ }
69
+ }
70
+ catch (error) {
71
+ const code = error.code;
72
+ if (code !== "ENOENT")
73
+ throw error;
74
+ }
75
+ // Write a new regular file and atomically replace the directory entry. This
76
+ // never opens an existing target for writing, so a pre-existing target
77
+ // symlink cannot redirect the token write even on platforms without
78
+ // O_NOFOLLOW. The containing directory follows the same-OS-user trust model
79
+ // documented above.
80
+ const tempPath = join(directory, `.${DASHBOARD_URL_FILE}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
81
+ const noFollow = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW;
82
+ const fd = openSync(tempPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, 0o600);
83
+ let tempExists = true;
84
+ try {
85
+ if (process.platform !== "win32")
86
+ fchmodSync(fd, 0o600);
87
+ writeFileSync(fd, `${JSON.stringify({ version: 1, url: validated, probe_key: validatedProbeKey })}\n`, "utf8");
88
+ }
89
+ finally {
90
+ closeSync(fd);
91
+ }
92
+ try {
93
+ renameSync(tempPath, filePath);
94
+ tempExists = false;
95
+ if (process.platform !== "win32")
96
+ chmodSync(filePath, 0o600);
97
+ }
98
+ finally {
99
+ if (tempExists) {
100
+ try {
101
+ unlinkSync(tempPath);
102
+ }
103
+ catch { /* best-effort cleanup */ }
104
+ }
105
+ }
106
+ return filePath;
107
+ }
108
+ export function readDashboardAccessState(home = homedir()) {
109
+ dashboardAccessDirectory(home);
110
+ const filePath = dashboardAccessUrlPath(home);
111
+ const noFollow = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW;
112
+ let fd;
113
+ try {
114
+ fd = openSync(filePath, fsConstants.O_RDONLY | noFollow);
115
+ }
116
+ catch (error) {
117
+ const code = error.code;
118
+ if (code === "ENOENT") {
119
+ throw new Error("No current local dashboard link. Restart your connected MCP host first.");
120
+ }
121
+ if (code === "ELOOP")
122
+ throw new Error("Local dashboard link path is not a regular file");
123
+ throw error;
124
+ }
125
+ try {
126
+ // Validate and read the same opened object. A path-level lstat followed by
127
+ // readFileSync(path) permits the directory entry to be swapped between the
128
+ // check and use; O_NOFOLLOW plus fstat/read on this descriptor closes that
129
+ // race on POSIX without broadening the same-OS-user trust boundary.
130
+ const stat = fstatSync(fd);
131
+ if (!stat.isFile())
132
+ throw new Error("Local dashboard link path is not a regular file");
133
+ if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) {
134
+ throw new Error("Local dashboard link file permissions are unsafe");
135
+ }
136
+ const raw = readFileSync(fd, "utf8");
137
+ if (!raw || Buffer.byteLength(raw, "utf8") > MAX_DASHBOARD_URL_BYTES) {
138
+ throw new Error("Local dashboard link is missing or invalid");
139
+ }
140
+ let parsed;
141
+ try {
142
+ parsed = JSON.parse(raw);
143
+ }
144
+ catch {
145
+ throw new Error("Local dashboard link is invalid");
146
+ }
147
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
148
+ throw new Error("Local dashboard link is invalid");
149
+ }
150
+ const state = parsed;
151
+ if (state.version !== 1 || typeof state.url !== "string" || typeof state.probe_key !== "string") {
152
+ throw new Error("Local dashboard link is invalid");
153
+ }
154
+ return {
155
+ url: validateDashboardUrl(state.url),
156
+ probeKey: validateDashboardProbeKey(state.probe_key),
157
+ };
158
+ }
159
+ finally {
160
+ closeSync(fd);
161
+ }
162
+ }
163
+ export function readDashboardAccessUrl(home = homedir()) {
164
+ return readDashboardAccessState(home).url;
165
+ }
166
+ export async function isLocalDashboardRunning(dashboardUrl, probeKey, fetcher = fetch) {
167
+ const validated = validateDashboardUrl(dashboardUrl);
168
+ const challenge = createDashboardProbeRequest(probeKey);
169
+ const probe = new URL(DASHBOARD_PROBE_PATH, validated);
170
+ probe.searchParams.set("nonce", challenge.nonce);
171
+ probe.searchParams.set("proof", challenge.proof);
172
+ try {
173
+ const response = await fetcher(probe, {
174
+ redirect: "error",
175
+ signal: AbortSignal.timeout(1_500),
176
+ });
177
+ if (!response.ok)
178
+ return false;
179
+ const finalUrl = response.url ? new URL(response.url) : probe;
180
+ if (finalUrl.href !== probe.href) {
181
+ return false;
182
+ }
183
+ const body = await response.json();
184
+ return body.name === "Prism Mind Palace" &&
185
+ body.nonce === challenge.nonce &&
186
+ typeof body.proof === "string" &&
187
+ verifyDashboardProbeResponse(probeKey, challenge.nonce, body.proof);
188
+ }
189
+ catch {
190
+ return false;
191
+ }
192
+ }
193
+ export function dashboardOpenCommand(url, platform = process.platform) {
194
+ const validated = validateDashboardUrl(url);
195
+ if (platform === "darwin")
196
+ return { command: "open", args: [validated] };
197
+ if (platform === "win32") {
198
+ return { command: "rundll32", args: ["url.dll,FileProtocolHandler", validated] };
199
+ }
200
+ return { command: "xdg-open", args: [validated] };
201
+ }
202
+ export function openDashboardUrl(url, platform = process.platform, runner = (command, args) => spawnSync(command, args, {
203
+ shell: false,
204
+ stdio: "ignore",
205
+ timeout: 5_000,
206
+ windowsHide: true,
207
+ })) {
208
+ const launch = dashboardOpenCommand(url, platform);
209
+ const result = runner(launch.command, launch.args);
210
+ if (result.error || result.status !== 0) {
211
+ throw new Error("Could not open the local dashboard browser");
212
+ }
213
+ }