prism-mcp-server 20.21.10 → 20.21.12
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 +23 -0
- package/dist/cli.js +3 -6
- package/dist/dashboard/dashboardAccess.js +222 -41
- package/dist/dashboard/server.js +8 -2
- package/dist/dashboard/ui.js +4 -4
- package/dist/storage/index.js +24 -3
- package/dist/tools/ledgerHandlers.js +20 -4
- package/dist/utils/synaluxJwt.js +105 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -151,6 +151,29 @@ or by re-enabling after each run.
|
|
|
151
151
|
<details>
|
|
152
152
|
<summary>Release history (optional)</summary>
|
|
153
153
|
|
|
154
|
+
## What's New in v20.21.12
|
|
155
|
+
|
|
156
|
+
### Multiple MCP hosts keep a working dashboard link
|
|
157
|
+
|
|
158
|
+
- Each running Prism host now registers its local dashboard independently.
|
|
159
|
+
- `prism dashboard` and the startup message verify the newest candidates and
|
|
160
|
+
fall back to an older live dashboard when a newer host stops.
|
|
161
|
+
- Instance cleanup cannot remove another host's registration, and the open
|
|
162
|
+
message preserves the linked account and plan description.
|
|
163
|
+
|
|
164
|
+
## What's New in v20.21.11
|
|
165
|
+
|
|
166
|
+
### Linked accounts persist across dashboard refreshes
|
|
167
|
+
|
|
168
|
+
- A host process with an obsolete launch credential now recovers from the
|
|
169
|
+
newer account saved by the dashboard after the obsolete credential is
|
|
170
|
+
rejected.
|
|
171
|
+
- Valid host credentials remain authoritative; sign-out, concurrent account
|
|
172
|
+
changes, network failures, and server failures do not trigger an unsafe
|
|
173
|
+
credential switch.
|
|
174
|
+
- `prism dashboard` opens the active local dashboard without relabeling a
|
|
175
|
+
linked Standard, Advanced, or Enterprise account as Free.
|
|
176
|
+
|
|
154
177
|
## What's New in v20.21.10
|
|
155
178
|
|
|
156
179
|
### Signed-out Free dashboards keep working locally
|
package/dist/cli.js
CHANGED
|
@@ -15,7 +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 {
|
|
18
|
+
import { dashboardOpenSuccessMessage, findRunningDashboardAccessState, openDashboardUrl, } from './dashboard/dashboardAccess.js';
|
|
19
19
|
const program = new Command();
|
|
20
20
|
/** Build the stable `prism load --json` envelope from depth-specific context. */
|
|
21
21
|
export function buildLoadJsonOutput(project, data, level, metadata) {
|
|
@@ -121,16 +121,13 @@ program
|
|
|
121
121
|
.option('--print', 'Print the current local dashboard link instead of opening a browser')
|
|
122
122
|
.action(async (options) => {
|
|
123
123
|
try {
|
|
124
|
-
const { url
|
|
125
|
-
if (!(await isLocalDashboardRunning(url, probeKey))) {
|
|
126
|
-
throw new Error('The recorded Prism dashboard is not running. Restart your connected MCP host first.');
|
|
127
|
-
}
|
|
124
|
+
const { url } = await findRunningDashboardAccessState();
|
|
128
125
|
if (options.print) {
|
|
129
126
|
console.log(url);
|
|
130
127
|
return;
|
|
131
128
|
}
|
|
132
129
|
openDashboardUrl(url);
|
|
133
|
-
console.log(
|
|
130
|
+
console.log(dashboardOpenSuccessMessage());
|
|
134
131
|
}
|
|
135
132
|
catch (error) {
|
|
136
133
|
console.error(`Dashboard unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -1,14 +1,19 @@
|
|
|
1
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";
|
|
2
|
+
import { chmodSync, closeSync, constants as fsConstants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
5
|
+
import { basename, join } from "node:path";
|
|
6
6
|
import { createDashboardProbeRequest, DASHBOARD_PROBE_PATH, generateDashboardProbeKey, validateDashboardProbeKey, verifyDashboardProbeResponse, } from "./dashboardProbe.js";
|
|
7
7
|
const DASHBOARD_URL_FILE = "dashboard.url";
|
|
8
|
+
const DASHBOARD_REGISTRY_DIRECTORY = "dashboard-instances";
|
|
8
9
|
const MAX_DASHBOARD_URL_BYTES = 4096;
|
|
10
|
+
const DASHBOARD_INSTANCE_ID = /^[a-f0-9]{32}$/;
|
|
9
11
|
export function dashboardAccessUrlPath(home = homedir()) {
|
|
10
12
|
return join(home, ".prism-mcp", DASHBOARD_URL_FILE);
|
|
11
13
|
}
|
|
14
|
+
export function dashboardAccessRegistryPath(home = homedir()) {
|
|
15
|
+
return join(home, ".prism-mcp", DASHBOARD_REGISTRY_DIRECTORY);
|
|
16
|
+
}
|
|
12
17
|
function dashboardAccessDirectory(home) {
|
|
13
18
|
const directory = join(home, ".prism-mcp");
|
|
14
19
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
@@ -20,6 +25,18 @@ function dashboardAccessDirectory(home) {
|
|
|
20
25
|
chmodSync(directory, 0o700);
|
|
21
26
|
return directory;
|
|
22
27
|
}
|
|
28
|
+
function dashboardRegistryDirectory(home) {
|
|
29
|
+
dashboardAccessDirectory(home);
|
|
30
|
+
const directory = dashboardAccessRegistryPath(home);
|
|
31
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
32
|
+
const stat = lstatSync(directory);
|
|
33
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
34
|
+
throw new Error("Local dashboard registry is not a regular directory");
|
|
35
|
+
}
|
|
36
|
+
if (process.platform !== "win32")
|
|
37
|
+
chmodSync(directory, 0o700);
|
|
38
|
+
return directory;
|
|
39
|
+
}
|
|
23
40
|
function validateDashboardUrl(raw) {
|
|
24
41
|
const value = raw.trim();
|
|
25
42
|
if (!value || Buffer.byteLength(value, "utf8") > MAX_DASHBOARD_URL_BYTES) {
|
|
@@ -49,22 +66,11 @@ function validateDashboardUrl(raw) {
|
|
|
49
66
|
}
|
|
50
67
|
return parsed.toString();
|
|
51
68
|
}
|
|
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);
|
|
69
|
+
function writeOwnerOnlyJson(filePath, directory, value, label) {
|
|
64
70
|
try {
|
|
65
71
|
const existing = lstatSync(filePath);
|
|
66
72
|
if (!existing.isFile() || existing.isSymbolicLink()) {
|
|
67
|
-
throw new Error(
|
|
73
|
+
throw new Error(`${label} path is not a regular file`);
|
|
68
74
|
}
|
|
69
75
|
}
|
|
70
76
|
catch (error) {
|
|
@@ -77,14 +83,14 @@ export function writeDashboardAccessUrl(url, home = homedir(), probeKey = genera
|
|
|
77
83
|
// symlink cannot redirect the token write even on platforms without
|
|
78
84
|
// O_NOFOLLOW. The containing directory follows the same-OS-user trust model
|
|
79
85
|
// documented above.
|
|
80
|
-
const tempPath = join(directory, `.${
|
|
86
|
+
const tempPath = join(directory, `.${basename(filePath)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
81
87
|
const noFollow = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW;
|
|
82
88
|
const fd = openSync(tempPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, 0o600);
|
|
83
89
|
let tempExists = true;
|
|
84
90
|
try {
|
|
85
91
|
if (process.platform !== "win32")
|
|
86
92
|
fchmodSync(fd, 0o600);
|
|
87
|
-
writeFileSync(fd, `${JSON.stringify(
|
|
93
|
+
writeFileSync(fd, `${JSON.stringify(value)}\n`, "utf8");
|
|
88
94
|
}
|
|
89
95
|
finally {
|
|
90
96
|
closeSync(fd);
|
|
@@ -105,9 +111,7 @@ export function writeDashboardAccessUrl(url, home = homedir(), probeKey = genera
|
|
|
105
111
|
}
|
|
106
112
|
return filePath;
|
|
107
113
|
}
|
|
108
|
-
|
|
109
|
-
dashboardAccessDirectory(home);
|
|
110
|
-
const filePath = dashboardAccessUrlPath(home);
|
|
114
|
+
function readOwnerOnlyJson(filePath, label, missingMessage) {
|
|
111
115
|
const noFollow = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW;
|
|
112
116
|
let fd;
|
|
113
117
|
try {
|
|
@@ -115,11 +119,10 @@ export function readDashboardAccessState(home = homedir()) {
|
|
|
115
119
|
}
|
|
116
120
|
catch (error) {
|
|
117
121
|
const code = error.code;
|
|
118
|
-
if (code === "ENOENT")
|
|
119
|
-
throw new Error(
|
|
120
|
-
}
|
|
122
|
+
if (code === "ENOENT" && missingMessage)
|
|
123
|
+
throw new Error(missingMessage);
|
|
121
124
|
if (code === "ELOOP")
|
|
122
|
-
throw new Error(
|
|
125
|
+
throw new Error(`${label} path is not a regular file`);
|
|
123
126
|
throw error;
|
|
124
127
|
}
|
|
125
128
|
try {
|
|
@@ -129,40 +132,215 @@ export function readDashboardAccessState(home = homedir()) {
|
|
|
129
132
|
// race on POSIX without broadening the same-OS-user trust boundary.
|
|
130
133
|
const stat = fstatSync(fd);
|
|
131
134
|
if (!stat.isFile())
|
|
132
|
-
throw new Error(
|
|
135
|
+
throw new Error(`${label} path is not a regular file`);
|
|
133
136
|
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) {
|
|
134
|
-
throw new Error(
|
|
137
|
+
throw new Error(`${label} file permissions are unsafe`);
|
|
135
138
|
}
|
|
136
139
|
const raw = readFileSync(fd, "utf8");
|
|
137
140
|
if (!raw || Buffer.byteLength(raw, "utf8") > MAX_DASHBOARD_URL_BYTES) {
|
|
138
|
-
throw new Error(
|
|
141
|
+
throw new Error(`${label} is missing or invalid`);
|
|
139
142
|
}
|
|
140
|
-
let parsed;
|
|
141
143
|
try {
|
|
142
|
-
|
|
144
|
+
return JSON.parse(raw);
|
|
143
145
|
}
|
|
144
146
|
catch {
|
|
145
|
-
throw new Error(
|
|
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");
|
|
147
|
+
throw new Error(`${label} is invalid`);
|
|
153
148
|
}
|
|
154
|
-
return {
|
|
155
|
-
url: validateDashboardUrl(state.url),
|
|
156
|
-
probeKey: validateDashboardProbeKey(state.probe_key),
|
|
157
|
-
};
|
|
158
149
|
}
|
|
159
150
|
finally {
|
|
160
151
|
closeSync(fd);
|
|
161
152
|
}
|
|
162
153
|
}
|
|
154
|
+
function parseDashboardAccessState(parsed, label) {
|
|
155
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
156
|
+
throw new Error(`${label} is invalid`);
|
|
157
|
+
}
|
|
158
|
+
const state = parsed;
|
|
159
|
+
if (state.version !== 1 || typeof state.url !== "string" || typeof state.probe_key !== "string") {
|
|
160
|
+
throw new Error(`${label} is invalid`);
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
url: validateDashboardUrl(state.url),
|
|
164
|
+
probeKey: validateDashboardProbeKey(state.probe_key),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Persist the latest local dashboard link for older Prism clients. Current
|
|
169
|
+
* clients discover live dashboards from the per-instance registry below.
|
|
170
|
+
* The token is a localhost capability, so the file is owner-only and symlinks
|
|
171
|
+
* present at validation time are rejected rather than followed. This is not a
|
|
172
|
+
* boundary against another process running as the same OS user: that process
|
|
173
|
+
* can already read or replace owner-only Prism state.
|
|
174
|
+
*/
|
|
175
|
+
export function writeDashboardAccessUrl(url, home = homedir(), probeKey = generateDashboardProbeKey()) {
|
|
176
|
+
const validated = validateDashboardUrl(url);
|
|
177
|
+
const validatedProbeKey = validateDashboardProbeKey(probeKey);
|
|
178
|
+
const directory = dashboardAccessDirectory(home);
|
|
179
|
+
return writeOwnerOnlyJson(dashboardAccessUrlPath(home), directory, { version: 1, url: validated, probe_key: validatedProbeKey }, "Local dashboard link");
|
|
180
|
+
}
|
|
181
|
+
export function readDashboardAccessState(home = homedir()) {
|
|
182
|
+
dashboardAccessDirectory(home);
|
|
183
|
+
return parseDashboardAccessState(readOwnerOnlyJson(dashboardAccessUrlPath(home), "Local dashboard link", "No current local dashboard link. Restart your connected MCP host first."), "Local dashboard link");
|
|
184
|
+
}
|
|
163
185
|
export function readDashboardAccessUrl(home = homedir()) {
|
|
164
186
|
return readDashboardAccessState(home).url;
|
|
165
187
|
}
|
|
188
|
+
function validateDashboardInstanceId(value) {
|
|
189
|
+
if (!DASHBOARD_INSTANCE_ID.test(value)) {
|
|
190
|
+
throw new Error("Local dashboard instance ID is invalid");
|
|
191
|
+
}
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
function validateRegistrationInteger(value, label, allowZero = false) {
|
|
195
|
+
if (!Number.isSafeInteger(value) || (allowZero ? value < 0 : value < 1)) {
|
|
196
|
+
throw new Error(`Local dashboard ${label} is invalid`);
|
|
197
|
+
}
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
function parseDashboardAccessRecord(parsed, recordPath, expectedInstanceId) {
|
|
201
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
202
|
+
throw new Error("Local dashboard registry record is invalid");
|
|
203
|
+
}
|
|
204
|
+
const record = parsed;
|
|
205
|
+
if (record.version !== 1 ||
|
|
206
|
+
typeof record.instance_id !== "string" ||
|
|
207
|
+
typeof record.pid !== "number" ||
|
|
208
|
+
typeof record.registered_at_ms !== "number" ||
|
|
209
|
+
typeof record.url !== "string" ||
|
|
210
|
+
typeof record.probe_key !== "string") {
|
|
211
|
+
throw new Error("Local dashboard registry record is invalid");
|
|
212
|
+
}
|
|
213
|
+
const instanceId = validateDashboardInstanceId(record.instance_id);
|
|
214
|
+
if (instanceId !== expectedInstanceId) {
|
|
215
|
+
throw new Error("Local dashboard registry record is invalid");
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
instanceId,
|
|
219
|
+
pid: validateRegistrationInteger(record.pid, "process ID"),
|
|
220
|
+
registeredAtMs: validateRegistrationInteger(record.registered_at_ms, "registration time", true),
|
|
221
|
+
url: validateDashboardUrl(record.url),
|
|
222
|
+
probeKey: validateDashboardProbeKey(record.probe_key),
|
|
223
|
+
recordPath,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function readDashboardAccessRecord(recordPath, instanceId) {
|
|
227
|
+
return parseDashboardAccessRecord(readOwnerOnlyJson(recordPath, "Local dashboard registry record"), recordPath, instanceId);
|
|
228
|
+
}
|
|
229
|
+
function unregisterDashboardAccessRecord(record) {
|
|
230
|
+
let current;
|
|
231
|
+
try {
|
|
232
|
+
current = readDashboardAccessRecord(record.recordPath, record.instanceId);
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
if (error.code === "ENOENT")
|
|
236
|
+
return;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (current.pid !== record.pid ||
|
|
240
|
+
current.registeredAtMs !== record.registeredAtMs ||
|
|
241
|
+
current.url !== record.url ||
|
|
242
|
+
current.probeKey !== record.probeKey) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
unlinkSync(record.recordPath);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// Best effort: an unreadable registry must not block another live instance.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function isDashboardProcessAlive(pid) {
|
|
253
|
+
try {
|
|
254
|
+
process.kill(pid, 0);
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
// EPERM proves the process exists but is owned by a different principal.
|
|
259
|
+
return error.code === "EPERM";
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** Register one running dashboard without replacing records for other hosts. */
|
|
263
|
+
export function registerDashboardAccessUrl(url, home = homedir(), probeKey = generateDashboardProbeKey(), options = {}) {
|
|
264
|
+
const validatedUrl = validateDashboardUrl(url);
|
|
265
|
+
const validatedProbeKey = validateDashboardProbeKey(probeKey);
|
|
266
|
+
const instanceId = validateDashboardInstanceId(options.instanceId ?? randomBytes(16).toString("hex"));
|
|
267
|
+
const pid = validateRegistrationInteger(options.pid ?? process.pid, "process ID");
|
|
268
|
+
const registeredAtMs = validateRegistrationInteger(options.registeredAtMs ?? Date.now(), "registration time", true);
|
|
269
|
+
// Preserve the singleton file for clients released before the registry.
|
|
270
|
+
writeDashboardAccessUrl(validatedUrl, home, validatedProbeKey);
|
|
271
|
+
const directory = dashboardRegistryDirectory(home);
|
|
272
|
+
const recordPath = join(directory, `${instanceId}.json`);
|
|
273
|
+
const record = {
|
|
274
|
+
instanceId,
|
|
275
|
+
pid,
|
|
276
|
+
registeredAtMs,
|
|
277
|
+
url: validatedUrl,
|
|
278
|
+
probeKey: validatedProbeKey,
|
|
279
|
+
recordPath,
|
|
280
|
+
};
|
|
281
|
+
writeOwnerOnlyJson(recordPath, directory, {
|
|
282
|
+
version: 1,
|
|
283
|
+
instance_id: instanceId,
|
|
284
|
+
pid,
|
|
285
|
+
registered_at_ms: registeredAtMs,
|
|
286
|
+
url: validatedUrl,
|
|
287
|
+
probe_key: validatedProbeKey,
|
|
288
|
+
}, "Local dashboard registry record");
|
|
289
|
+
return {
|
|
290
|
+
recordPath,
|
|
291
|
+
state: { url: validatedUrl, probeKey: validatedProbeKey },
|
|
292
|
+
unregister: () => unregisterDashboardAccessRecord(record),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function readDashboardAccessRecords(home) {
|
|
296
|
+
const directory = dashboardRegistryDirectory(home);
|
|
297
|
+
const records = [];
|
|
298
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
299
|
+
const match = /^([a-f0-9]{32})\.json$/.exec(entry.name);
|
|
300
|
+
if (!match || !entry.isFile() || entry.isSymbolicLink())
|
|
301
|
+
continue;
|
|
302
|
+
try {
|
|
303
|
+
records.push(readDashboardAccessRecord(join(directory, entry.name), match[1]));
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
// A malformed or unsafe record can never become a dashboard capability.
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return records.sort((left, right) => right.registeredAtMs - left.registeredAtMs || right.instanceId.localeCompare(left.instanceId));
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Return the newest cryptographically verified dashboard that is still alive.
|
|
313
|
+
* Dead registry entries are removed one at a time; a surviving instance is
|
|
314
|
+
* never deleted just because a newer process stopped.
|
|
315
|
+
*/
|
|
316
|
+
export async function findRunningDashboardAccessState(home = homedir(), fetcher = fetch) {
|
|
317
|
+
const records = readDashboardAccessRecords(home);
|
|
318
|
+
const probed = new Set();
|
|
319
|
+
for (const record of records) {
|
|
320
|
+
const key = `${record.url}\n${record.probeKey}`;
|
|
321
|
+
probed.add(key);
|
|
322
|
+
if (await isLocalDashboardRunning(record.url, record.probeKey, fetcher)) {
|
|
323
|
+
return { url: record.url, probeKey: record.probeKey };
|
|
324
|
+
}
|
|
325
|
+
// One timeout is not proof that a live host is dead. Remove the record only
|
|
326
|
+
// when the owning PID is also gone; otherwise preserve it for a later retry.
|
|
327
|
+
if (!isDashboardProcessAlive(record.pid))
|
|
328
|
+
unregisterDashboardAccessRecord(record);
|
|
329
|
+
}
|
|
330
|
+
// A singleton-only record was written by Prism versions before this registry.
|
|
331
|
+
try {
|
|
332
|
+
const legacy = readDashboardAccessState(home);
|
|
333
|
+
const key = `${legacy.url}\n${legacy.probeKey}`;
|
|
334
|
+
if (!probed.has(key) && await isLocalDashboardRunning(legacy.url, legacy.probeKey, fetcher)) {
|
|
335
|
+
return legacy;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
if (records.length === 0)
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
throw new Error("No registered Prism dashboard is running. Restart your connected MCP host first.");
|
|
343
|
+
}
|
|
166
344
|
export async function isLocalDashboardRunning(dashboardUrl, probeKey, fetcher = fetch) {
|
|
167
345
|
const validated = validateDashboardUrl(dashboardUrl);
|
|
168
346
|
const challenge = createDashboardProbeRequest(probeKey);
|
|
@@ -190,6 +368,9 @@ export async function isLocalDashboardRunning(dashboardUrl, probeKey, fetcher =
|
|
|
190
368
|
return false;
|
|
191
369
|
}
|
|
192
370
|
}
|
|
371
|
+
export function dashboardOpenSuccessMessage() {
|
|
372
|
+
return "Opened the current local Prism dashboard in your default browser. Your Synalux account and plan are unchanged.";
|
|
373
|
+
}
|
|
193
374
|
export function dashboardOpenCommand(url, platform = process.platform) {
|
|
194
375
|
const validated = validateDashboardUrl(url);
|
|
195
376
|
if (platform === "darwin")
|
package/dist/dashboard/server.js
CHANGED
|
@@ -26,7 +26,7 @@ import { activeStorageBackend } from "../storage/index.js";
|
|
|
26
26
|
import { readDashboardLedger } from "./ledgerReader.js";
|
|
27
27
|
import { PRISM_USER_ID, SERVER_CONFIG } from "../config.js";
|
|
28
28
|
import { renderDashboardHTML, renderDashboardLocalOpenHTML } from "./ui.js";
|
|
29
|
-
import {
|
|
29
|
+
import { registerDashboardAccessUrl } from "./dashboardAccess.js";
|
|
30
30
|
import { createDashboardProbeResponse, DASHBOARD_PROBE_PATH, generateDashboardProbeKey, verifyDashboardProbeRequest, } from "./dashboardProbe.js";
|
|
31
31
|
import { computeIntentHealth } from "./intentHealth.js";
|
|
32
32
|
import { getAllSettings, setSetting, getSetting, getSettingSync } from "../storage/configStorage.js";
|
|
@@ -1488,7 +1488,13 @@ self.addEventListener('message', (e) => {
|
|
|
1488
1488
|
// Non-fatal — just means the user has to know the port
|
|
1489
1489
|
}
|
|
1490
1490
|
try {
|
|
1491
|
-
|
|
1491
|
+
const registration = registerDashboardAccessUrl(dashboardUrl, os.homedir(), DASHBOARD_PROBE_KEY);
|
|
1492
|
+
const unregister = registration.unregister;
|
|
1493
|
+
httpServer.once("close", () => {
|
|
1494
|
+
unregister();
|
|
1495
|
+
process.removeListener("exit", unregister);
|
|
1496
|
+
});
|
|
1497
|
+
process.once("exit", unregister);
|
|
1492
1498
|
}
|
|
1493
1499
|
catch (error) {
|
|
1494
1500
|
console.error(`[Dashboard] Could not save local opener link: ${error instanceof Error ? error.message : String(error)}`);
|
package/dist/dashboard/ui.js
CHANGED
|
@@ -34,12 +34,12 @@ export function renderDashboardLocalOpenHTML() {
|
|
|
34
34
|
</head>
|
|
35
35
|
<body>
|
|
36
36
|
<main>
|
|
37
|
-
<div class="eyebrow">Local
|
|
38
|
-
<h1>
|
|
39
|
-
<p>This browser
|
|
37
|
+
<div class="eyebrow">Local dashboard access</div>
|
|
38
|
+
<h1>Open the current Prism dashboard.</h1>
|
|
39
|
+
<p>This browser does not have the current local dashboard link. Run this command in Terminal to open the active dashboard securely. Your Synalux account and plan remain unchanged.</p>
|
|
40
40
|
<code>prism dashboard</code>
|
|
41
41
|
<button type="button" onclick="navigator.clipboard.writeText('prism dashboard').then(() => { this.textContent = 'Copied'; })">Copy command</button>
|
|
42
|
-
<p class="note">
|
|
42
|
+
<p class="note">This local browser access check is separate from Synalux sign-in.</p>
|
|
43
43
|
</main>
|
|
44
44
|
</body>
|
|
45
45
|
</html>`;
|
package/dist/storage/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { SupabaseStorage } from "./supabase.js";
|
|
|
4
4
|
import { getSetting } from "./configStorage.js";
|
|
5
5
|
import { upgradeInsecureCloudUrl } from "../utils/secureUrl.js";
|
|
6
6
|
import { isSynaluxSignedOut, setSynaluxSignedOut } from "../utils/synaluxCredentialState.js";
|
|
7
|
+
import { getSynaluxJwt, isUsableSynaluxApiKey } from "../utils/synaluxJwt.js";
|
|
7
8
|
export function isValidHttpUrl(url) {
|
|
8
9
|
try {
|
|
9
10
|
const parsed = new URL(url);
|
|
@@ -56,15 +57,35 @@ export async function ensureSynaluxCredentials() {
|
|
|
56
57
|
delete process.env.PRISM_SYNALUX_API_KEY;
|
|
57
58
|
return false;
|
|
58
59
|
}
|
|
59
|
-
if (SYNALUX_CONFIGURED)
|
|
60
|
-
return true;
|
|
61
60
|
// Re-check process.env directly: SYNALUX_CONFIGURED is captured at module
|
|
62
61
|
// load, so credentials injected later by another caller would be invisible
|
|
63
62
|
// to it. Mirrors ensureSupabaseCredentials below.
|
|
64
63
|
const rawEnvUrl = process.env.PRISM_SYNALUX_BASE_URL?.trim() || process.env.SYNALUX_BASE_URL?.trim();
|
|
65
64
|
const envUrl = rawEnvUrl ? upgradeInsecureCloudUrl(rawEnvUrl) : rawEnvUrl;
|
|
66
65
|
const envKey = process.env.PRISM_SYNALUX_API_KEY?.trim();
|
|
67
|
-
if (envUrl && envKey && isValidHttpUrl(envUrl))
|
|
66
|
+
if (envUrl && envKey && isValidHttpUrl(envUrl)) {
|
|
67
|
+
const storedKey = (await getSetting("PRISM_SYNALUX_API_KEY", ""))?.trim();
|
|
68
|
+
if (isUsableSynaluxApiKey(storedKey) &&
|
|
69
|
+
storedKey !== envKey) {
|
|
70
|
+
// Validate the launcher credential before SynaluxStorage captures it.
|
|
71
|
+
// getSynaluxJwt keeps a valid explicit env key authoritative and promotes
|
|
72
|
+
// the persisted key only after the env key receives 401/403 and the
|
|
73
|
+
// persisted key succeeds.
|
|
74
|
+
const validatedJwt = await getSynaluxJwt();
|
|
75
|
+
if (!validatedJwt) {
|
|
76
|
+
// A launcher can retain a revoked credential while the dashboard has
|
|
77
|
+
// already saved the replacement. Do not let SynaluxStorage capture
|
|
78
|
+
// the launcher key when the validation/recovery request is currently
|
|
79
|
+
// unavailable: its refresh token is immutable, so publishing that
|
|
80
|
+
// singleton would keep project access broken even after recovery.
|
|
81
|
+
throw new Error("[Prism Storage] Could not validate the active Synalux account credential. " +
|
|
82
|
+
"Refusing to initialize cloud storage with an unverified credential because that could " +
|
|
83
|
+
"strand project access or split session history. Retry when Synalux is reachable.");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
if (SYNALUX_CONFIGURED)
|
|
68
89
|
return true;
|
|
69
90
|
const rawUrl = (await getSetting("PRISM_SYNALUX_BASE_URL"))?.trim() ||
|
|
70
91
|
(await getSetting("SYNALUX_BASE_URL"))?.trim();
|
|
@@ -36,6 +36,7 @@ import { renderFloorDigestBlock, skillDigestFromSource, splitSkillFrontmatter }
|
|
|
36
36
|
import { mergeHandoff, dbToHandoffSchema, sanitizeForMerge } from "../utils/crdtMerge.js";
|
|
37
37
|
import { resolveProject } from "../utils/projectResolver.js";
|
|
38
38
|
import { getUpdateNotice } from "../updateNotice.js";
|
|
39
|
+
import { findRunningDashboardAccessState } from "../dashboard/dashboardAccess.js";
|
|
39
40
|
// The running server's own version, for the update-available notice. A read
|
|
40
41
|
// failure must never affect startup: an empty string fails the notice's
|
|
41
42
|
// semver gate, so no notice renders rather than a bogus "running 0.0.0".
|
|
@@ -610,14 +611,29 @@ function freeTierUpgradeLine(tier) {
|
|
|
610
611
|
* a private localhost capability, so startup advertises the local CLI opener
|
|
611
612
|
* rather than copying that link into agent context.
|
|
612
613
|
*/
|
|
613
|
-
async function
|
|
614
|
+
export async function readDashboardUrlForStartup(home = os.homedir()) {
|
|
615
|
+
const explicitPort = (process.env.PRISM_DASHBOARD_PORT || "").trim();
|
|
616
|
+
// Current Prism versions keep one signed record per dashboard instance. The
|
|
617
|
+
// newest record may belong to a host that just stopped, so use the same
|
|
618
|
+
// verified fallback selection as `prism dashboard` before consulting legacy
|
|
619
|
+
// singleton state. An explicit port remains authoritative for this process.
|
|
620
|
+
if (!explicitPort) {
|
|
621
|
+
try {
|
|
622
|
+
const state = await findRunningDashboardAccessState(home);
|
|
623
|
+
return new URL(state.url).origin;
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
// Continue into the legacy port probe for older installations and the
|
|
627
|
+
// startup instant before this process has written its registry record.
|
|
628
|
+
}
|
|
629
|
+
}
|
|
614
630
|
// Precedence: explicit env override > recorded port file > default. The
|
|
615
631
|
// file is written by whatever dashboard ran last and persists across boots,
|
|
616
632
|
// so it must never outrank configuration the operator set for THIS process.
|
|
617
|
-
let port =
|
|
633
|
+
let port = explicitPort;
|
|
618
634
|
if (!port) {
|
|
619
635
|
try {
|
|
620
|
-
const recorded = fs.readFileSync(nodePath.join(
|
|
636
|
+
const recorded = fs.readFileSync(nodePath.join(home, ".prism-mcp", "dashboard.port"), "utf8").trim();
|
|
621
637
|
if (/^\d{2,5}$/.test(recorded))
|
|
622
638
|
port = recorded;
|
|
623
639
|
}
|
|
@@ -2338,7 +2354,7 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
|
|
|
2338
2354
|
? greeting
|
|
2339
2355
|
: `${greeting}\n\n${identityBlock}${updateNotice ? `\n${updateNotice}` : ""}`;
|
|
2340
2356
|
if (projects.length === 0) {
|
|
2341
|
-
const dashboardUrl = await
|
|
2357
|
+
const dashboardUrl = await readDashboardUrlForStartup();
|
|
2342
2358
|
const dashboardLine = dashboardUrl
|
|
2343
2359
|
? `- 🎛️ **Dashboard:** run \`prism dashboard\` — opens locally with no Synalux account required`
|
|
2344
2360
|
: `- 🎛️ **Dashboard:** not running — start Prism's dashboard to configure projects, identity, and context depth`;
|
package/dist/utils/synaluxJwt.js
CHANGED
|
@@ -27,6 +27,67 @@ const MIN_CACHE_MS = 60_000;
|
|
|
27
27
|
let cache = null;
|
|
28
28
|
let inFlight = null;
|
|
29
29
|
let generation = 0;
|
|
30
|
+
async function exchangeJwt(baseUrl, apiKey) {
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(`${baseUrl}/api/v1/auth/jwt`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: {
|
|
35
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
},
|
|
38
|
+
signal: AbortSignal.timeout(10_000),
|
|
39
|
+
redirect: "error",
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
debugLog(`[synaluxJwt] exchange HTTP ${res.status}`);
|
|
43
|
+
return { kind: "http_error", status: res.status };
|
|
44
|
+
}
|
|
45
|
+
const data = (await res.json());
|
|
46
|
+
if (!data?.jwt) {
|
|
47
|
+
debugLog(`[synaluxJwt] exchange returned no jwt (status=${data?.status})`);
|
|
48
|
+
return { kind: "invalid_response" };
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
kind: "success",
|
|
52
|
+
jwt: data.jwt,
|
|
53
|
+
ttlMs: Math.max(MIN_CACHE_MS, (data.expires_in ?? 900) * 1000),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
debugLog(`[synaluxJwt] exchange error: ${err instanceof Error ? err.message : String(err)}`);
|
|
58
|
+
return { kind: "network_error" };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function readPersistedCredentialState() {
|
|
62
|
+
try {
|
|
63
|
+
const { getSetting } = await import("../storage/configStorage.js");
|
|
64
|
+
const signedOut = (await getSetting("PRISM_SYNALUX_SIGNED_OUT", "")) === "true";
|
|
65
|
+
const baseUrl = normalizePortalBaseUrl(await getSetting("PRISM_SYNALUX_BASE_URL", ""));
|
|
66
|
+
const apiKey = (await getSetting("PRISM_SYNALUX_API_KEY", "")).trim();
|
|
67
|
+
return { signedOut, baseUrl, apiKey };
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
debugLog(`[synaluxJwt] persisted credential lookup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function normalizePortalBaseUrl(raw) {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = new URL(raw.trim());
|
|
77
|
+
const loopback = parsed.hostname === "localhost" ||
|
|
78
|
+
parsed.hostname === "127.0.0.1" || parsed.hostname === "::1" || parsed.hostname === "[::1]";
|
|
79
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback))
|
|
80
|
+
return "";
|
|
81
|
+
return parsed.origin;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const SYNALUX_API_KEY_PREFIX = ["synalux", "sk", ""].join("_");
|
|
88
|
+
export function isUsableSynaluxApiKey(apiKey) {
|
|
89
|
+
return apiKey.startsWith(SYNALUX_API_KEY_PREFIX) && apiKey.length <= 512;
|
|
90
|
+
}
|
|
30
91
|
/**
|
|
31
92
|
* Returns a usable JWT, exchanging from the sk_ token if needed.
|
|
32
93
|
* Returns null when synalux is not configured or exchange fails.
|
|
@@ -54,24 +115,48 @@ export async function getSynaluxJwt() {
|
|
|
54
115
|
let exchange;
|
|
55
116
|
exchange = (async () => {
|
|
56
117
|
try {
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
118
|
+
let attempt = await exchangeJwt(baseUrl, apiKey);
|
|
119
|
+
const requestOrigin = normalizePortalBaseUrl(baseUrl);
|
|
120
|
+
// MCP launchers can keep an older environment snapshot after the
|
|
121
|
+
// dashboard saves a freshly-linked account token. A valid explicit
|
|
122
|
+
// environment token remains authoritative; only an authentication
|
|
123
|
+
// rejection may try the newer persisted token, and only once.
|
|
124
|
+
if (attempt.kind === "http_error" && (attempt.status === 401 || attempt.status === 403)) {
|
|
125
|
+
const persisted = await readPersistedCredentialState();
|
|
126
|
+
if (persisted &&
|
|
127
|
+
!persisted.signedOut &&
|
|
128
|
+
!isSynaluxSignedOut() &&
|
|
129
|
+
!!persisted.baseUrl &&
|
|
130
|
+
persisted.baseUrl === requestOrigin &&
|
|
131
|
+
isUsableSynaluxApiKey(persisted.apiKey) &&
|
|
132
|
+
persisted.apiKey !== apiKey) {
|
|
133
|
+
// The saved key may only recover the same portal origin.
|
|
134
|
+
// Callers can resolve their request URL before this helper
|
|
135
|
+
// returns, so cross-origin recovery would expose either the
|
|
136
|
+
// refresh key or its short-lived JWT to the launcher URL.
|
|
137
|
+
const recovered = await exchangeJwt(persisted.baseUrl, persisted.apiKey);
|
|
138
|
+
if (recovered.kind === "success") {
|
|
139
|
+
// Re-read after the network boundary. A sign-out or a
|
|
140
|
+
// newer account link must win over this older retry.
|
|
141
|
+
const current = await readPersistedCredentialState();
|
|
142
|
+
if (current &&
|
|
143
|
+
!current.signedOut &&
|
|
144
|
+
!isSynaluxSignedOut() &&
|
|
145
|
+
generation === requestGeneration &&
|
|
146
|
+
current.baseUrl === persisted.baseUrl &&
|
|
147
|
+
current.apiKey === persisted.apiKey) {
|
|
148
|
+
process.env.PRISM_SYNALUX_API_KEY = persisted.apiKey;
|
|
149
|
+
attempt = recovered;
|
|
150
|
+
debugLog("[synaluxJwt] recovered from a stale launcher credential");
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
cache = null;
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
71
158
|
}
|
|
72
|
-
|
|
73
|
-
if (!data?.jwt) {
|
|
74
|
-
debugLog(`[synaluxJwt] exchange returned no jwt (status=${data?.status})`);
|
|
159
|
+
if (attempt.kind !== "success") {
|
|
75
160
|
cache = null;
|
|
76
161
|
return null;
|
|
77
162
|
}
|
|
@@ -81,10 +166,9 @@ export async function getSynaluxJwt() {
|
|
|
81
166
|
if (isSynaluxSignedOut() || generation !== requestGeneration) {
|
|
82
167
|
return null;
|
|
83
168
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return data.jwt;
|
|
169
|
+
cache = { jwt: attempt.jwt, expiresAt: Date.now() + attempt.ttlMs };
|
|
170
|
+
debugLog(`[synaluxJwt] exchanged ok, ttl=${attempt.ttlMs}ms`);
|
|
171
|
+
return attempt.jwt;
|
|
88
172
|
}
|
|
89
173
|
catch (err) {
|
|
90
174
|
debugLog(`[synaluxJwt] exchange error: ${err instanceof Error ? err.message : String(err)}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prism-mcp-server",
|
|
3
|
-
"version": "20.21.
|
|
3
|
+
"version": "20.21.12",
|
|
4
4
|
"mcpName": "io.github.dcostenco/prism-coder",
|
|
5
5
|
"description": "Persistent session memory for AI coding agents that never leaves your machine — including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
|
|
6
6
|
"module": "index.ts",
|