@oh-agent/dsh-mcp-manager 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +351 -2
- package/README.zh.md +213 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +7 -0
- package/lib/adapters.js +346 -0
- package/lib/canonical.js +488 -0
- package/lib/client.js +1151 -0
- package/lib/dsh-patch.js +329 -0
- package/lib/dsh.js +259 -0
- package/lib/index.js +495 -0
- package/lib/ops.js +325 -0
- package/lib/probe.js +252 -0
- package/lib/scan.js +353 -0
- package/lib/store.js +257 -0
- package/lib/surgical.js +581 -0
- package/lib/toggle.js +194 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +66 -4
package/lib/canonical.js
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical MCP server model plus the dialect machinery every adapter shares.
|
|
3
|
+
*
|
|
4
|
+
* The fifteen-ish clients in the wild disagree on almost everything: the
|
|
5
|
+
* container key, whether `type` exists, five spellings for streamable HTTP,
|
|
6
|
+
* `env` vs `environment`, `command` as string vs array, three timeout units.
|
|
7
|
+
* None of that leaks upward: adapters decode into this model and encode back,
|
|
8
|
+
* preserving fields they do not understand so a rewrite never loses a user's
|
|
9
|
+
* client-specific option.
|
|
10
|
+
*
|
|
11
|
+
* Canonical transport is three values. Clients that key off the URL rather than
|
|
12
|
+
* a type tag still round-trip correctly because the decoder reads the shape.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const TRANSPORTS = Object.freeze(['stdio', 'http-stream', 'http-sse']);
|
|
16
|
+
|
|
17
|
+
/** Fields the canonical model owns; everything else rides in `extra`. */
|
|
18
|
+
const OWNED = new Set([
|
|
19
|
+
'type', 'command', 'args', 'env', 'environment', 'cwd',
|
|
20
|
+
'url', 'serverUrl', 'httpUrl', 'headers', 'disabled', 'enabled',
|
|
21
|
+
'timeout', 'timeoutMs', 'toolCallTimeoutMs',
|
|
22
|
+
'autoApprove', 'alwaysAllow',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/* ------------------------------------------------------------- secrets ---- */
|
|
26
|
+
|
|
27
|
+
const SECRET_KEY = /(token|key|secret|password|passwd|credential|auth|cookie|session|private)/i;
|
|
28
|
+
|
|
29
|
+
/** True when an env/header key name looks like it carries a credential. */
|
|
30
|
+
export function isSecretKey(key) {
|
|
31
|
+
return typeof key === 'string' && SECRET_KEY.test(key);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* True when a value is an indirection rather than the credential itself —
|
|
36
|
+
* `${VAR}`, `{{x}}`, `{env:X}`, `%VAR%`, `<placeholder>`. These are safe to show
|
|
37
|
+
* because they name a secret instead of carrying one.
|
|
38
|
+
*/
|
|
39
|
+
export function isReferenceValue(value) {
|
|
40
|
+
return typeof value === 'string' && /^\$\{|\{\{|\$env:|^\{env:|^%[A-Za-z_]+%$|^<.*>$/.test(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A literal value shaped like a real credential (long, opaque).
|
|
45
|
+
*
|
|
46
|
+
* Only used to *rank* findings — never to decide whether to mask. Masking is
|
|
47
|
+
* key-driven, because a short secret (`ZZ_TOKEN: abc`) is still a secret and a
|
|
48
|
+
* heuristic that inspects values would happily print it.
|
|
49
|
+
*/
|
|
50
|
+
export function isSecretValue(value) {
|
|
51
|
+
if (typeof value !== 'string') return false;
|
|
52
|
+
if (value.length < 12) return false;
|
|
53
|
+
if (isReferenceValue(value)) return false;
|
|
54
|
+
return /[A-Za-z0-9_\-]{16,}/.test(value) || value.length >= 24;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function maskValue(value) {
|
|
58
|
+
const text = String(value ?? '');
|
|
59
|
+
if (text.length <= 8) return '••••';
|
|
60
|
+
return `${text.slice(0, 4)}••••${text.slice(-4)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Is this string a placeholder produced by `maskValue`?
|
|
65
|
+
*
|
|
66
|
+
* A masked copy must never be written back to a config file: it would replace a
|
|
67
|
+
* real credential with bullets and silently break the server. Write paths use
|
|
68
|
+
* this to refuse, and to explain why.
|
|
69
|
+
*/
|
|
70
|
+
export function isMaskedValue(value) {
|
|
71
|
+
return typeof value === 'string' && value.includes('••••');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ---------------------------------------------------------- normalization -- */
|
|
75
|
+
|
|
76
|
+
function asStringArray(value) {
|
|
77
|
+
if (Array.isArray(value)) return value.map((item) => String(item));
|
|
78
|
+
if (value === undefined || value === null || value === '') return [];
|
|
79
|
+
return [String(value)];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function asObject(value) {
|
|
83
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function pickTimeout(entry) {
|
|
87
|
+
if (typeof entry.timeoutMs === 'number') return entry.timeoutMs;
|
|
88
|
+
if (typeof entry.toolCallTimeoutMs === 'number') return entry.toolCallTimeoutMs;
|
|
89
|
+
if (typeof entry.timeout === 'number') {
|
|
90
|
+
// `timeout` is milliseconds in Gemini/Qwen, seconds in Roo/Codex.
|
|
91
|
+
return entry.timeout * (entry.__timeoutUnit === 's' ? 1000 : 1);
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Decode one raw client entry into the canonical model.
|
|
98
|
+
*
|
|
99
|
+
* `dialect` carries what the client means by ambiguous fields:
|
|
100
|
+
* { urlKey, typeMap, envKey, commandArray, timeoutUnit }
|
|
101
|
+
*/
|
|
102
|
+
export function decodeEntry(name, entry, dialect = {}) {
|
|
103
|
+
const raw = asObject(entry);
|
|
104
|
+
const urlKey = dialect.urlKey ?? 'url';
|
|
105
|
+
const envKey = dialect.envKey ?? 'env';
|
|
106
|
+
const typeMap = dialect.typeMap ?? {};
|
|
107
|
+
const declaredType = typeof raw.type === 'string' ? raw.type.toLowerCase() : '';
|
|
108
|
+
const mapped = typeMap[declaredType] ?? null;
|
|
109
|
+
|
|
110
|
+
const url = raw[urlKey] ?? raw.url ?? raw.httpUrl ?? raw.serverUrl;
|
|
111
|
+
let transport = 'stdio';
|
|
112
|
+
if (mapped) transport = mapped;
|
|
113
|
+
else if (typeof url === 'string' && url) {
|
|
114
|
+
// No usable type tag: infer. `sse` hint wins, otherwise streamable HTTP.
|
|
115
|
+
const hint = (declaredType || String(raw.__sseHint ?? '')).toLowerCase();
|
|
116
|
+
transport = hint === 'sse' ? 'http-sse' : 'http-stream';
|
|
117
|
+
} else if (declaredType === 'sse') transport = 'http-sse';
|
|
118
|
+
else if (declaredType && ['http', 'streamable-http', 'streamablehttp', 'remote'].includes(declaredType)) transport = 'http-stream';
|
|
119
|
+
else if (['local', 'stdio'].includes(declaredType)) transport = 'stdio';
|
|
120
|
+
else if (raw.command !== undefined) transport = 'stdio';
|
|
121
|
+
|
|
122
|
+
const commandValue = raw.command;
|
|
123
|
+
const commandArray = Array.isArray(commandValue);
|
|
124
|
+
const commandParts = commandArray ? asStringArray(commandValue) : [String(commandValue ?? '')];
|
|
125
|
+
const command = commandParts[0] ?? '';
|
|
126
|
+
const args = commandArray ? commandParts.slice(1) : asStringArray(raw.args);
|
|
127
|
+
|
|
128
|
+
const env = {};
|
|
129
|
+
const headers = {};
|
|
130
|
+
for (const [key, value] of Object.entries(asObject(raw[envKey]))) env[key] = value;
|
|
131
|
+
for (const [key, value] of Object.entries(asObject(raw.headers))) headers[key] = value;
|
|
132
|
+
|
|
133
|
+
const extra = {};
|
|
134
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
135
|
+
if (OWNED.has(key)) continue;
|
|
136
|
+
if (key === urlKey) continue;
|
|
137
|
+
extra[key] = value;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const enabled = raw.disabled === true ? false : raw.enabled === false ? false : true;
|
|
141
|
+
const autoApproveRaw = raw.autoApprove ?? raw.alwaysAllow;
|
|
142
|
+
const autoApprove = autoApproveRaw === true ? true : Array.isArray(autoApproveRaw) ? autoApproveRaw.map(String) : [];
|
|
143
|
+
|
|
144
|
+
/*
|
|
145
|
+
* Any credential-named field holding a literal is a finding; `literal`
|
|
146
|
+
* separates an opaque-looking value from a short one so the UI can rank them
|
|
147
|
+
* without either being hidden.
|
|
148
|
+
*/
|
|
149
|
+
const secrets = [];
|
|
150
|
+
for (const [key, value] of Object.entries(env)) {
|
|
151
|
+
if (isSecretKey(key) && !isReferenceValue(value) && String(value ?? '').length > 0) secrets.push({ where: 'env', key, literal: isSecretValue(value) });
|
|
152
|
+
}
|
|
153
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
154
|
+
if (isSecretKey(key) && !isReferenceValue(value) && String(value ?? '').length > 0) secrets.push({ where: 'header', key, literal: isSecretValue(value) });
|
|
155
|
+
}
|
|
156
|
+
for (const found of secretArgs(args)) {
|
|
157
|
+
if (!isReferenceValue(String(found.value))) secrets.push({ where: 'args', key: found.key, literal: isSecretValue(String(found.value)) });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
name,
|
|
162
|
+
transport,
|
|
163
|
+
command,
|
|
164
|
+
args,
|
|
165
|
+
env,
|
|
166
|
+
cwd: typeof raw.cwd === 'string' ? raw.cwd : undefined,
|
|
167
|
+
url: typeof url === 'string' ? url : undefined,
|
|
168
|
+
headers,
|
|
169
|
+
timeoutMs: pickTimeout({ ...raw, __timeoutUnit: dialect.timeoutUnit }),
|
|
170
|
+
enabled,
|
|
171
|
+
autoApprove,
|
|
172
|
+
extra,
|
|
173
|
+
secrets,
|
|
174
|
+
declaredType: raw.type,
|
|
175
|
+
raw,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Encode the canonical model back into one client's entry shape, using the
|
|
181
|
+
* dialect's own spellings and units. Unknown fields in `extra` survive.
|
|
182
|
+
*/
|
|
183
|
+
export function encodeEntry(server, dialect = {}, previous = undefined) {
|
|
184
|
+
const urlKey = dialect.urlKey ?? 'url';
|
|
185
|
+
const envKey = dialect.envKey ?? 'env';
|
|
186
|
+
const typeMap = dialect.typeMap ?? {};
|
|
187
|
+
const reverse = dialect.reverseType ?? {};
|
|
188
|
+
const out = { ...asObject(server.extra) };
|
|
189
|
+
|
|
190
|
+
if (server.transport === 'stdio') {
|
|
191
|
+
const parts = [server.command, ...(server.args ?? [])].filter((part) => part !== undefined && part !== '');
|
|
192
|
+
if (dialect.commandArray) out.command = parts;
|
|
193
|
+
else {
|
|
194
|
+
out.command = server.command ?? '';
|
|
195
|
+
if (server.args && server.args.length) out.args = [...server.args];
|
|
196
|
+
else delete out.args;
|
|
197
|
+
}
|
|
198
|
+
if (Object.keys(server.env ?? {}).length) out[envKey] = { ...server.env };
|
|
199
|
+
else delete out[envKey];
|
|
200
|
+
if (server.cwd) out.cwd = server.cwd;
|
|
201
|
+
else delete out.cwd;
|
|
202
|
+
delete out[urlKey];
|
|
203
|
+
if (urlKey !== 'url') delete out.url;
|
|
204
|
+
delete out.headers;
|
|
205
|
+
} else {
|
|
206
|
+
out[urlKey] = server.url ?? '';
|
|
207
|
+
if (Object.keys(server.headers ?? {}).length) out.headers = { ...server.headers };
|
|
208
|
+
else delete out.headers;
|
|
209
|
+
delete out.command;
|
|
210
|
+
delete out.args;
|
|
211
|
+
delete out[envKey];
|
|
212
|
+
if (envKey !== 'env') delete out.env;
|
|
213
|
+
delete out.cwd;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// `type`: only written where the client interprets it, with its own spelling.
|
|
217
|
+
const typeValue = reverse[server.transport];
|
|
218
|
+
if (typeValue !== undefined && typeValue !== null) out.type = typeValue;
|
|
219
|
+
else if (dialect.typeRequired) out.type = server.transport === 'stdio' ? (dialect.stdioType ?? 'stdio') : (typeMap.http ?? 'http');
|
|
220
|
+
else if (dialect.dropUnknownType) delete out.type;
|
|
221
|
+
else if (typeof server.declaredType === 'string' && !reverse[server.transport]) out.type = server.declaredType;
|
|
222
|
+
else delete out.type;
|
|
223
|
+
|
|
224
|
+
// Timeout in the client's own unit.
|
|
225
|
+
delete out.timeoutMs;
|
|
226
|
+
delete out.toolCallTimeoutMs;
|
|
227
|
+
if (typeof server.timeoutMs === 'number' && server.timeoutMs > 0) {
|
|
228
|
+
if (dialect.timeoutField) out[dialect.timeoutField] = dialect.timeoutUnit === 's' ? Math.round(server.timeoutMs / 1000) : server.timeoutMs;
|
|
229
|
+
else delete out.timeout;
|
|
230
|
+
} else {
|
|
231
|
+
delete out.timeout;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (server.enabled === false) {
|
|
235
|
+
if (dialect.enabledField === 'enabled') out.enabled = false;
|
|
236
|
+
else out.disabled = true;
|
|
237
|
+
} else {
|
|
238
|
+
if (dialect.enabledField === 'enabled') { if (out.enabled === false) out.enabled = true; }
|
|
239
|
+
else delete out.disabled;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const autoField = dialect.autoApproveField;
|
|
243
|
+
if (autoField) {
|
|
244
|
+
const value = server.autoApprove;
|
|
245
|
+
if (value === true) out[autoField] = true;
|
|
246
|
+
else if (Array.isArray(value) && value.length) out[autoField] = [...value];
|
|
247
|
+
else delete out[autoField];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Keep undefined out of the serialized form.
|
|
251
|
+
for (const key of Object.keys(out)) if (out[key] === undefined) delete out[key];
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/* --------------------------------------------------------------- plumbing -- */
|
|
256
|
+
|
|
257
|
+
const NAME_SAFE = /[^A-Za-z0-9_-]+/g;
|
|
258
|
+
|
|
259
|
+
/** Sanitize an external server name into `[A-Za-z0-9_-]{1,32}`. */
|
|
260
|
+
export function sanitizeName(raw, fallback = 'server') {
|
|
261
|
+
const cleaned = String(raw ?? '').trim().replace(NAME_SAFE, '-').replace(/^-+|-+$/g, '').slice(0, 32);
|
|
262
|
+
return cleaned || fallback;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The `serverName` to mount a server under inside DeepSeek Harness.
|
|
267
|
+
*
|
|
268
|
+
* The harness's own `@deepseek-ai/dsh-mcp-client` constrains `serverName` to
|
|
269
|
+
* `/^[A-Za-z0-9_-]{1,32}$/` (lib/index.js of that package, the zod schema and
|
|
270
|
+
* `SERVER_NAME_PATTERN`), so a name like `表单管理系统 - API 文档` CANNOT be used
|
|
271
|
+
* verbatim. It has to be mapped.
|
|
272
|
+
*
|
|
273
|
+
* `sanitizeName` alone is not a usable mapping here: every CJK name collapses
|
|
274
|
+
* to whatever ASCII fragments it contains — `表单管理系统 - API 文档`,
|
|
275
|
+
* `方案生成助手 - API 文档` and `心理健康关爱系统 - API 文档` ALL become `API`, and
|
|
276
|
+
* for a fully-CJK name it falls back to the constant `server`. That collision is
|
|
277
|
+
* not cosmetic. It produced two visible bugs: the mount row was created under
|
|
278
|
+
* `API`, the page looked the mount up by the display name (found nothing), so
|
|
279
|
+
* the switch showed "off" for a mounted server — and `waitForRow` waited for a
|
|
280
|
+
* row named after the display name, never saw it, and told the user to restart.
|
|
281
|
+
*
|
|
282
|
+
* So there are two shapes, both pure functions of the display name:
|
|
283
|
+
*
|
|
284
|
+
* 1. An all-ASCII name keeps `sanitizeName`'s result unchanged. Those names
|
|
285
|
+
* are already readable and already distinct from each other, so
|
|
286
|
+
* `Figma AI Bridge` stays `Figma-AI-Bridge` and `my server!` stays
|
|
287
|
+
* `my-server` — which also means rows mounted before this mapping existed
|
|
288
|
+
* keep their identity.
|
|
289
|
+
* 2. A name with any non-ASCII character — CJK, full-width punctuation,
|
|
290
|
+
* emoji — is exactly the case that collapses under `sanitizeName`, so it
|
|
291
|
+
* gets a short deterministic suffix derived from the ORIGINAL name.
|
|
292
|
+
* `fingerprint` is the FNV-1a helper in this module, so the same display
|
|
293
|
+
* name always yields the same mount name (a repeat apply updates the row
|
|
294
|
+
* instead of accumulating one per click) while two different CJK names
|
|
295
|
+
* cannot collide.
|
|
296
|
+
*/
|
|
297
|
+
export function dshMountName(raw) {
|
|
298
|
+
const original = String(raw ?? '').trim();
|
|
299
|
+
if (!original) return 'server';
|
|
300
|
+
if (/^[A-Za-z0-9_-]{1,32}$/.test(original)) return original;
|
|
301
|
+
const base = sanitizeName(original, '');
|
|
302
|
+
if (/^[\x20-\x7E]+$/.test(original) && base) return base;
|
|
303
|
+
return `${(base || 'mcp').slice(0, 22)}-${fingerprint(original).slice(0, 8)}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Return `name`, or `name-2`/`name-3`… when taken. */
|
|
307
|
+
export function uniqueName(name, taken) {
|
|
308
|
+
if (!taken.has(name)) return name;
|
|
309
|
+
for (let index = 2; index < 1000; index += 1) {
|
|
310
|
+
const candidate = `${name.slice(0, 28)}-${index}`;
|
|
311
|
+
if (!taken.has(candidate)) return candidate;
|
|
312
|
+
}
|
|
313
|
+
throw new Error(`cannot derive a unique name for ${name}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Deterministic short fingerprint for change detection. */
|
|
317
|
+
export function fingerprint(value) {
|
|
318
|
+
const text = stableStringify(value ?? null);
|
|
319
|
+
let hash = 0x811c9dc5;
|
|
320
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
321
|
+
hash ^= text.charCodeAt(i);
|
|
322
|
+
hash = (hash * 0x01000193) >>> 0;
|
|
323
|
+
}
|
|
324
|
+
return hash.toString(16).padStart(8, '0');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** JSON with object keys sorted, so equal objects always hash equally. */
|
|
328
|
+
function stableStringify(value) {
|
|
329
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
330
|
+
if (value && typeof value === 'object') {
|
|
331
|
+
const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort();
|
|
332
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
|
|
333
|
+
}
|
|
334
|
+
return JSON.stringify(value ?? null);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The fields that define a server, exactly as `sameDefinition` compares them.
|
|
339
|
+
* One normalizer for both hashing and equality keeps the ledger, the scan and
|
|
340
|
+
* the write guard from disagreeing about what "the same entry" means.
|
|
341
|
+
*/
|
|
342
|
+
function definitionShape(server) {
|
|
343
|
+
return {
|
|
344
|
+
transport: server?.transport ?? 'stdio',
|
|
345
|
+
command: server?.command ?? null,
|
|
346
|
+
args: server?.args ?? [],
|
|
347
|
+
env: server?.env ?? {},
|
|
348
|
+
cwd: server?.cwd ?? null,
|
|
349
|
+
url: server?.url ?? null,
|
|
350
|
+
headers: server?.headers ?? {},
|
|
351
|
+
timeoutMs: server?.timeoutMs ?? null,
|
|
352
|
+
autoApprove: server?.autoApprove ?? [],
|
|
353
|
+
enabled: server?.enabled !== false,
|
|
354
|
+
extra: server?.extra ?? {},
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Same definition, semantically. */
|
|
359
|
+
export function sameDefinition(left, right) {
|
|
360
|
+
return stableStringify(definitionShape(left)) === stableStringify(definitionShape(right));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Stable identity of a definition, for the ownership ledger and drift checks. */
|
|
364
|
+
export function definitionHash(server) {
|
|
365
|
+
return fingerprint(definitionShape(server));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Compare two canonical servers field by field, so the UI can show what a sync
|
|
370
|
+
* would actually change instead of a bare "differs".
|
|
371
|
+
*/
|
|
372
|
+
export function diffServers(left, right) {
|
|
373
|
+
const fields = ['transport', 'command', 'args', 'env', 'cwd', 'url', 'headers', 'timeoutMs', 'enabled'];
|
|
374
|
+
const changed = [];
|
|
375
|
+
for (const field of fields) {
|
|
376
|
+
const a = JSON.stringify(left?.[field] ?? null);
|
|
377
|
+
const b = JSON.stringify(right?.[field] ?? null);
|
|
378
|
+
if (a !== b) changed.push({ field, from: left?.[field] ?? null, to: right?.[field] ?? null });
|
|
379
|
+
}
|
|
380
|
+
return changed;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Match an argument that carries a credential, in either of the two shapes
|
|
385
|
+
* CLIs accept:
|
|
386
|
+
*
|
|
387
|
+
* --figma-api-key=figd_xxx (inline value)
|
|
388
|
+
* --api-key figd_xxx (separate value — the *next* argument)
|
|
389
|
+
*
|
|
390
|
+
* The command line is a first-class place for secrets: `npx figma-developer-mcp
|
|
391
|
+
* --figma-api-key=…` is the documented invocation, so masking env and headers
|
|
392
|
+
* alone leaves real tokens in the payload.
|
|
393
|
+
*/
|
|
394
|
+
const SECRET_ARG_INLINE = /^(--?[A-Za-z0-9_.-]*(?:token|key|secret|password|passwd|credential|auth|cookie|session|private)[A-Za-z0-9_.-]*)=(.*)$/i;
|
|
395
|
+
const SECRET_ARG_FLAG = /^--?[A-Za-z0-9_.-]*(?:token|key|secret|password|passwd|credential|auth|cookie|session|private)[A-Za-z0-9_.-]*$/i;
|
|
396
|
+
|
|
397
|
+
/** Find credential-bearing arguments; returns [{index, key, value}] for masking. */
|
|
398
|
+
export function secretArgs(args) {
|
|
399
|
+
const out = [];
|
|
400
|
+
const list = Array.isArray(args) ? args : [];
|
|
401
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
402
|
+
const text = String(list[index] ?? '');
|
|
403
|
+
const inline = text.match(SECRET_ARG_INLINE);
|
|
404
|
+
if (inline) { out.push({ index, key: inline[1], value: inline[2] }); continue; }
|
|
405
|
+
if (SECRET_ARG_FLAG.test(text)) {
|
|
406
|
+
const next = list[index + 1];
|
|
407
|
+
if (typeof next === 'string' && next && !next.startsWith('-')) out.push({ index: index + 1, key: text, value: next });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return out;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Mask every credential-bearing argument, leaving the flag visible. */
|
|
414
|
+
export function redactArgs(args) {
|
|
415
|
+
const list = Array.isArray(args) ? [...args] : [];
|
|
416
|
+
for (const found of secretArgs(list)) {
|
|
417
|
+
const token = String(found.value ?? '');
|
|
418
|
+
if (!token || isReferenceValue(token)) continue;
|
|
419
|
+
const masked = maskValue(token);
|
|
420
|
+
const current = String(list[found.index] ?? '');
|
|
421
|
+
/*
|
|
422
|
+
* When the secret shared an argument with its flag, keep the flag: mask the
|
|
423
|
+
* value portion only, otherwise the argument no longer reads as a flag.
|
|
424
|
+
*/
|
|
425
|
+
list[found.index] = current.startsWith(`${found.key}=`) ? `${found.key}=${masked}` : masked;
|
|
426
|
+
}
|
|
427
|
+
return list;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Strip credential literals before a server object crosses to the browser.
|
|
432
|
+
*
|
|
433
|
+
* Redaction covers every place a literal can hide, not just the two maps the
|
|
434
|
+
* canonical model owns: `args` is the documented home of `--api-key=…` flags,
|
|
435
|
+
* `raw` holds a full copy of the original entry, and `extra` holds unmodelled
|
|
436
|
+
* client options, so a token in an unusual key would otherwise ride along
|
|
437
|
+
* inside the scan payload and into the model's context. Masking is
|
|
438
|
+
* depth-limited and cycle-safe because `raw` is client JSON, which has been
|
|
439
|
+
* known to nest arbitrarily.
|
|
440
|
+
*/
|
|
441
|
+
export function redactServer(server, reveal = false) {
|
|
442
|
+
if (reveal || !server) return server;
|
|
443
|
+
|
|
444
|
+
const seen = new WeakSet();
|
|
445
|
+
const scrub = (value, keyHint, depth) => {
|
|
446
|
+
if (depth > 8) return '…';
|
|
447
|
+
if (typeof value === 'string') {
|
|
448
|
+
/*
|
|
449
|
+
* Key-driven masking: a credential-named field is masked whatever its
|
|
450
|
+
* length, except when the value is an indirection that only names the
|
|
451
|
+
* secret. Masking on value shape alone would leak short tokens.
|
|
452
|
+
*/
|
|
453
|
+
return keyHint && isSecretKey(keyHint) && !isReferenceValue(value) ? maskValue(value) : value;
|
|
454
|
+
}
|
|
455
|
+
if (Array.isArray(value)) {
|
|
456
|
+
/*
|
|
457
|
+
* A nested argument list obeys the same flag rule as the top-level one;
|
|
458
|
+
* `raw` carries the client's own copy of `args`, so masking only the
|
|
459
|
+
* modelled field would still ship the literal inside it.
|
|
460
|
+
*/
|
|
461
|
+
if (keyHint === 'args') return redactArgs(value);
|
|
462
|
+
return value.map((item) => scrub(item, keyHint, depth + 1));
|
|
463
|
+
}
|
|
464
|
+
if (value && typeof value === 'object') {
|
|
465
|
+
if (seen.has(value)) return '[circular]';
|
|
466
|
+
seen.add(value);
|
|
467
|
+
const out = {};
|
|
468
|
+
for (const [key, inner] of Object.entries(value)) out[key] = scrub(inner, key, depth + 1);
|
|
469
|
+
seen.delete(value);
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
return value;
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const out = {};
|
|
476
|
+
for (const [key, value] of Object.entries(server)) {
|
|
477
|
+
if (key === 'env' || key === 'headers' || key === 'extra' || key === 'raw') {
|
|
478
|
+
out[key] = scrub(value, null, 0);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (key === 'args') {
|
|
482
|
+
out[key] = redactArgs(value);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
out[key] = value;
|
|
486
|
+
}
|
|
487
|
+
return out;
|
|
488
|
+
}
|