scalix 1.5.2 → 1.6.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/CHANGELOG.md +40 -0
- package/README.md +44 -1
- package/dist/agent.js +140 -26
- package/dist/api-client.js +62 -3
- package/dist/cli.js +131 -4
- package/dist/command-sandbox.js +146 -0
- package/dist/commands.js +18 -6
- package/dist/constants.js +8 -0
- package/dist/cost-tracker.js +1 -1
- package/dist/headless.js +21 -4
- package/dist/ipc-server.js +42 -11
- package/dist/marketplace.js +400 -0
- package/dist/mcp.js +45 -18
- package/dist/providers/index.js +21 -4
- package/dist/secure-fetch.js +117 -0
- package/dist/settings.js +193 -44
- package/dist/slash-commands.js +2 -0
- package/dist/sub-agent.js +3 -0
- package/dist/tools-browser.js +8 -2
- package/dist/tools-extra.js +86 -4
- package/dist/tools-web.js +26 -23
- package/dist/tools.js +84 -9
- package/dist/workspace-trust.js +5 -1
- package/dist/worktree.js +24 -5
- package/package.json +3 -3
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scalix Marketplace client — the curated plugin catalog.
|
|
3
|
+
*
|
|
4
|
+
* Coder/Editor/IDE all install the same artifacts (`mcp-services.json`,
|
|
5
|
+
* `.scalix/skills/`, `.scalix/rules/`, `.scalix/agents/`), and this module is
|
|
6
|
+
* the one place Coder learns about what the catalog offers and writes it to
|
|
7
|
+
* disk. The catalog is SERVER data: everything it returns is untrusted input
|
|
8
|
+
* until validated here, which is why every payload is re-checked client-side
|
|
9
|
+
* (path traversal, transport shape, credential-looking values) before a
|
|
10
|
+
* single byte touches the workspace. A compromised backend must not be able
|
|
11
|
+
* to write outside this project's own config directory.
|
|
12
|
+
*
|
|
13
|
+
* Contract: scalix-cloud/docs/MARKETPLACE.md (the crate that serves this is
|
|
14
|
+
* crates/marketplace). When one changes, the other changes.
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs/promises';
|
|
17
|
+
import fsSync from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { normalizeMcpConfig, validateMcpUrl } from './mcp.js';
|
|
20
|
+
import { logger } from './logger.js';
|
|
21
|
+
/** Same env override the IDE/Editor clients accept, so one variable redirects all three. */
|
|
22
|
+
const DEFAULT_MARKETPLACE_URL = 'https://api.scalix.world/v1/marketplace';
|
|
23
|
+
export function marketplaceBaseUrl(env = process.env) {
|
|
24
|
+
const override = env.SCALIX_MARKETPLACE_URL?.trim();
|
|
25
|
+
return (override || DEFAULT_MARKETPLACE_URL).replace(/\/+$/, '');
|
|
26
|
+
}
|
|
27
|
+
const KINDS = ['mcp', 'skill', 'rule', 'subagent'];
|
|
28
|
+
const TRUSTS = ['official', 'verified', 'community'];
|
|
29
|
+
const SURFACES = ['coder', 'ide', 'editor', 'console'];
|
|
30
|
+
/** Size mirror of the server's caps — a listing bigger than this is a bug upstream. */
|
|
31
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
32
|
+
const MAX_FILES = 10;
|
|
33
|
+
// ── Client-side validation (server already enforced these; we re-check) ─────
|
|
34
|
+
const LISTING_ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
35
|
+
const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+([-+][A-Za-z0-9.-_]{0,32})?$/;
|
|
36
|
+
const ENV_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
37
|
+
const BARE_COMMAND_RE = /^[A-Za-z0-9._@-]{1,64}$/;
|
|
38
|
+
/** Relative path under a listing's install dir: no roots, no traversal, no backslashes. */
|
|
39
|
+
export function isSafeMarketplacePath(rel) {
|
|
40
|
+
if (!rel || rel.length > 256)
|
|
41
|
+
return false;
|
|
42
|
+
if (rel.startsWith('/') || rel.startsWith('\\') || rel.includes('\\'))
|
|
43
|
+
return false;
|
|
44
|
+
if (rel.includes('..'))
|
|
45
|
+
return false;
|
|
46
|
+
const segments = rel.split('/');
|
|
47
|
+
if (segments.length > 6)
|
|
48
|
+
return false;
|
|
49
|
+
return segments.every(s => s.length > 0 &&
|
|
50
|
+
s !== '.' &&
|
|
51
|
+
!s.startsWith('-') &&
|
|
52
|
+
/^[A-Za-z0-9._ -]+$/.test(s));
|
|
53
|
+
}
|
|
54
|
+
function isPlainRecord(value) {
|
|
55
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
56
|
+
}
|
|
57
|
+
/** Parse + narrow one API payload into the discriminated union, or null. */
|
|
58
|
+
export function parsePayload(raw) {
|
|
59
|
+
if (!isPlainRecord(raw))
|
|
60
|
+
return null;
|
|
61
|
+
const kind = raw.kind;
|
|
62
|
+
if (kind === 'mcp') {
|
|
63
|
+
const transport = raw.transport;
|
|
64
|
+
if (transport !== 'http' && transport !== 'stdio')
|
|
65
|
+
return null;
|
|
66
|
+
if (typeof raw.serverLabel !== 'string' || raw.serverLabel.length < 2)
|
|
67
|
+
return null;
|
|
68
|
+
const payload = { kind: 'mcp', transport, serverLabel: raw.serverLabel };
|
|
69
|
+
if (transport === 'http') {
|
|
70
|
+
// Catalog contract is STRICTER than a hand-written config: https only
|
|
71
|
+
// (validateMcpUrl itself allows plain http for local servers), plus the
|
|
72
|
+
// shared SSRF/private-host guard — a compromised backend must not be
|
|
73
|
+
// able to aim a "curated" entry at 169.254.169.254 with our headers.
|
|
74
|
+
if (typeof raw.serverUrl !== 'string')
|
|
75
|
+
return null;
|
|
76
|
+
if (!raw.serverUrl.startsWith('https://'))
|
|
77
|
+
return null;
|
|
78
|
+
const blocked = validateMcpUrl(raw.serverUrl);
|
|
79
|
+
if (blocked)
|
|
80
|
+
return null;
|
|
81
|
+
payload.serverUrl = raw.serverUrl;
|
|
82
|
+
if (raw.authorizationEnv !== undefined) {
|
|
83
|
+
if (typeof raw.authorizationEnv !== 'string' || !ENV_RE.test(raw.authorizationEnv))
|
|
84
|
+
return null;
|
|
85
|
+
payload.authorizationEnv = raw.authorizationEnv;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
if (typeof raw.command !== 'string' || !BARE_COMMAND_RE.test(raw.command))
|
|
90
|
+
return null;
|
|
91
|
+
payload.command = raw.command;
|
|
92
|
+
if (raw.args !== undefined) {
|
|
93
|
+
if (!Array.isArray(raw.args) || raw.args.some(a => typeof a !== 'string' || a.length > 512))
|
|
94
|
+
return null;
|
|
95
|
+
payload.args = raw.args;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (raw.headers !== undefined) {
|
|
99
|
+
if (!isPlainRecord(raw.headers))
|
|
100
|
+
return null;
|
|
101
|
+
const headers = {};
|
|
102
|
+
for (const [k, v] of Object.entries(raw.headers)) {
|
|
103
|
+
if (typeof v !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(k))
|
|
104
|
+
return null;
|
|
105
|
+
headers[k] = v;
|
|
106
|
+
}
|
|
107
|
+
payload.headers = headers;
|
|
108
|
+
}
|
|
109
|
+
if (raw.requiredEnv !== undefined) {
|
|
110
|
+
if (!Array.isArray(raw.requiredEnv))
|
|
111
|
+
return null;
|
|
112
|
+
const names = [];
|
|
113
|
+
for (const name of raw.requiredEnv) {
|
|
114
|
+
if (typeof name !== 'string' || !ENV_RE.test(name))
|
|
115
|
+
return null;
|
|
116
|
+
names.push(name);
|
|
117
|
+
}
|
|
118
|
+
payload.requiredEnv = names;
|
|
119
|
+
}
|
|
120
|
+
return payload;
|
|
121
|
+
}
|
|
122
|
+
if (kind === 'skill') {
|
|
123
|
+
if (typeof raw.name !== 'string' || raw.name.length < 2)
|
|
124
|
+
return null;
|
|
125
|
+
if (!Array.isArray(raw.files) || raw.files.length === 0 || raw.files.length > MAX_FILES)
|
|
126
|
+
return null;
|
|
127
|
+
const files = [];
|
|
128
|
+
for (const file of raw.files) {
|
|
129
|
+
if (!isPlainRecord(file) || typeof file.path !== 'string' || typeof file.content !== 'string')
|
|
130
|
+
return null;
|
|
131
|
+
if (!isSafeMarketplacePath(file.path))
|
|
132
|
+
return null;
|
|
133
|
+
if (file.content.length > MAX_FILE_BYTES)
|
|
134
|
+
return null;
|
|
135
|
+
files.push({ path: file.path, content: file.content });
|
|
136
|
+
}
|
|
137
|
+
if (!files.some(f => f.path === 'SKILL.md'))
|
|
138
|
+
return null;
|
|
139
|
+
return { kind: 'skill', name: raw.name, files };
|
|
140
|
+
}
|
|
141
|
+
if (kind === 'rule' || kind === 'subagent') {
|
|
142
|
+
if (typeof raw.fileName !== 'string' || !raw.fileName.endsWith('.md'))
|
|
143
|
+
return null;
|
|
144
|
+
if (!isSafeMarketplacePath(raw.fileName))
|
|
145
|
+
return null;
|
|
146
|
+
if (typeof raw.content !== 'string' || !raw.content || raw.content.length > MAX_FILE_BYTES)
|
|
147
|
+
return null;
|
|
148
|
+
return { kind, fileName: raw.fileName, content: raw.content };
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
function parseSummary(raw) {
|
|
153
|
+
if (!isPlainRecord(raw))
|
|
154
|
+
return null;
|
|
155
|
+
const { id, kind, name, summary } = raw;
|
|
156
|
+
if (typeof id !== 'string' || !LISTING_ID_RE.test(id))
|
|
157
|
+
return null;
|
|
158
|
+
if (typeof kind !== 'string' || !KINDS.includes(kind))
|
|
159
|
+
return null;
|
|
160
|
+
if (typeof name !== 'string' || !name)
|
|
161
|
+
return null;
|
|
162
|
+
if (typeof summary !== 'string')
|
|
163
|
+
return null;
|
|
164
|
+
const trust = typeof raw.trust === 'string' && TRUSTS.includes(raw.trust)
|
|
165
|
+
? raw.trust
|
|
166
|
+
: 'community';
|
|
167
|
+
const arrayOf = (value) => Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
|
|
168
|
+
return {
|
|
169
|
+
id,
|
|
170
|
+
kind: kind,
|
|
171
|
+
name,
|
|
172
|
+
summary,
|
|
173
|
+
author: typeof raw.author === 'string' ? raw.author : '',
|
|
174
|
+
trust,
|
|
175
|
+
install_count: typeof raw.install_count === 'number' ? raw.install_count : 0,
|
|
176
|
+
version: typeof raw.version === 'string' ? raw.version : '',
|
|
177
|
+
featured: raw.featured === true,
|
|
178
|
+
categories: arrayOf(raw.categories),
|
|
179
|
+
tags: arrayOf(raw.tags),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// ── API ─────────────────────────────────────────────────────────────────────
|
|
183
|
+
async function fetchJson(url, timeoutMs = 10_000) {
|
|
184
|
+
const response = await fetch(url, {
|
|
185
|
+
headers: { accept: 'application/json' },
|
|
186
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
187
|
+
});
|
|
188
|
+
if (!response.ok) {
|
|
189
|
+
throw new Error(`marketplace responded ${response.status}`);
|
|
190
|
+
}
|
|
191
|
+
return response.json();
|
|
192
|
+
}
|
|
193
|
+
export async function listMarketplaceListings(options = {}) {
|
|
194
|
+
const base = options.baseUrl ?? marketplaceBaseUrl();
|
|
195
|
+
const params = new URLSearchParams();
|
|
196
|
+
if (options.q)
|
|
197
|
+
params.set('q', options.q);
|
|
198
|
+
if (options.kind)
|
|
199
|
+
params.set('kind', options.kind);
|
|
200
|
+
params.set('limit', String(Math.min(Math.max(options.limit ?? 50, 1), 100)));
|
|
201
|
+
if (options.offset)
|
|
202
|
+
params.set('offset', String(options.offset));
|
|
203
|
+
const body = await fetchJson(`${base}/listings?${params.toString()}`);
|
|
204
|
+
if (!isPlainRecord(body) || !Array.isArray(body.listings)) {
|
|
205
|
+
throw new Error('marketplace returned an unexpected listings payload');
|
|
206
|
+
}
|
|
207
|
+
const parsed = [];
|
|
208
|
+
for (const entry of body.listings) {
|
|
209
|
+
const summary = parseSummary(entry);
|
|
210
|
+
if (summary)
|
|
211
|
+
parsed.push(summary);
|
|
212
|
+
}
|
|
213
|
+
return parsed;
|
|
214
|
+
}
|
|
215
|
+
export async function getMarketplaceListing(id, baseUrl) {
|
|
216
|
+
if (!LISTING_ID_RE.test(id))
|
|
217
|
+
throw new Error(`invalid marketplace listing id: ${id}`);
|
|
218
|
+
const base = baseUrl ?? marketplaceBaseUrl();
|
|
219
|
+
const raw = await fetchJson(`${base}/listings/${encodeURIComponent(id)}`);
|
|
220
|
+
const summary = parseSummary(raw);
|
|
221
|
+
if (!summary || !isPlainRecord(raw))
|
|
222
|
+
throw new Error('marketplace returned an unexpected listing payload');
|
|
223
|
+
const detail = {
|
|
224
|
+
...summary,
|
|
225
|
+
description: typeof raw.description === 'string' ? raw.description : '',
|
|
226
|
+
homepage_url: typeof raw.homepage_url === 'string' ? raw.homepage_url : '',
|
|
227
|
+
icon_url: typeof raw.icon_url === 'string' ? raw.icon_url : '',
|
|
228
|
+
payload: parsePayload(raw.payload),
|
|
229
|
+
};
|
|
230
|
+
return detail;
|
|
231
|
+
}
|
|
232
|
+
/** Best-effort anonymous telemetry. Never fails an install. */
|
|
233
|
+
export async function reportMarketplaceInstall(detail, surface = 'coder') {
|
|
234
|
+
try {
|
|
235
|
+
if (!SURFACES.includes(surface))
|
|
236
|
+
return;
|
|
237
|
+
if (!LISTING_ID_RE.test(detail.id) || !VERSION_RE.test(detail.version))
|
|
238
|
+
return;
|
|
239
|
+
await fetch(`${marketplaceBaseUrl()}/installs`, {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers: { 'content-type': 'application/json' },
|
|
242
|
+
body: JSON.stringify({ listing_id: detail.id, version: detail.version, surface }),
|
|
243
|
+
signal: AbortSignal.timeout(5_000),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
logger.debug(`marketplace install telemetry skipped: ${err?.message ?? err}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// ── Install ─────────────────────────────────────────────────────────────────
|
|
251
|
+
const MCP_CONFIG = 'mcp-services.json';
|
|
252
|
+
async function readWorkspaceFile(rel) {
|
|
253
|
+
try {
|
|
254
|
+
return await fs.readFile(path.resolve(rel), 'utf-8');
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Merge one MCP entry into `mcp-services.json`, preserving every existing
|
|
262
|
+
* server (through normalizeMcpConfig, so the object-map dialect the IDE panel
|
|
263
|
+
* writes survives). Returns the relative path touched.
|
|
264
|
+
*/
|
|
265
|
+
async function installMcpEntry(payload, options) {
|
|
266
|
+
const configPath = path.resolve(options.cwd ?? process.cwd(), MCP_CONFIG);
|
|
267
|
+
let config = { mcpServers: [] };
|
|
268
|
+
const existing = await readWorkspaceFile(configPath);
|
|
269
|
+
if (existing) {
|
|
270
|
+
const parsed = normalizeMcpConfig(JSON.parse(existing));
|
|
271
|
+
config.mcpServers = Array.isArray(parsed?.mcpServers) ? parsed.mcpServers : [];
|
|
272
|
+
}
|
|
273
|
+
const entry = { serverLabel: payload.serverLabel };
|
|
274
|
+
if (payload.transport === 'http') {
|
|
275
|
+
const blocked = validateMcpUrl(payload.serverUrl);
|
|
276
|
+
if (blocked) {
|
|
277
|
+
return { success: false, message: '', paths: [], error: `refused marketplace MCP URL: ${blocked}` };
|
|
278
|
+
}
|
|
279
|
+
entry.serverUrl = payload.serverUrl;
|
|
280
|
+
if (payload.authorizationEnv)
|
|
281
|
+
entry.authorizationEnv = payload.authorizationEnv;
|
|
282
|
+
if (payload.headers && Object.keys(payload.headers).length > 0)
|
|
283
|
+
entry.headers = payload.headers;
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
// A stdio entry spawns a local process — the caller must have confirmed
|
|
287
|
+
// explicitly (the slash command asks; the agent tool refuses without it).
|
|
288
|
+
if (!options.confirmStdio) {
|
|
289
|
+
return {
|
|
290
|
+
success: false,
|
|
291
|
+
message: '',
|
|
292
|
+
paths: [],
|
|
293
|
+
error: 'this listing installs a local MCP server (stdio) — re-run with explicit confirmation',
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
entry.command = payload.command;
|
|
297
|
+
entry.args = payload.args ?? [];
|
|
298
|
+
}
|
|
299
|
+
if (config.mcpServers.some(s => s?.serverLabel === payload.serverLabel)) {
|
|
300
|
+
return {
|
|
301
|
+
success: false,
|
|
302
|
+
message: '',
|
|
303
|
+
paths: [],
|
|
304
|
+
error: `an MCP server labelled "${payload.serverLabel}" is already configured`,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
config.mcpServers.push(entry);
|
|
308
|
+
config.mcpServers = config.mcpServers.filter((s) => Boolean(s));
|
|
309
|
+
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
|
|
310
|
+
const envNote = payload.requiredEnv?.length
|
|
311
|
+
? ` Set ${payload.requiredEnv.join(', ')} in your environment before restarting the session.`
|
|
312
|
+
: '';
|
|
313
|
+
return {
|
|
314
|
+
success: true,
|
|
315
|
+
message: `Added MCP server ${payload.serverLabel} to ${MCP_CONFIG} (restart or /mcp to connect).${envNote}`,
|
|
316
|
+
paths: [MCP_CONFIG],
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/** Write a listing's files under its install directory, re-validating every path. */
|
|
320
|
+
async function writeListingFiles(files, targetDir, workspaceCwd) {
|
|
321
|
+
const written = [];
|
|
322
|
+
const resolvedTarget = path.resolve(targetDir);
|
|
323
|
+
for (const file of files) {
|
|
324
|
+
if (!isSafeMarketplacePath(file.path)) {
|
|
325
|
+
throw new Error(`refusing unsafe install path: ${file.path}`);
|
|
326
|
+
}
|
|
327
|
+
const target = path.resolve(resolvedTarget, file.path);
|
|
328
|
+
if (!target.startsWith(resolvedTarget + path.sep)) {
|
|
329
|
+
throw new Error(`refusing path outside the install directory: ${file.path}`);
|
|
330
|
+
}
|
|
331
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
332
|
+
await fs.writeFile(target, file.content, 'utf-8');
|
|
333
|
+
written.push(path.relative(workspaceCwd, target));
|
|
334
|
+
}
|
|
335
|
+
return written;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Install one listing into the current workspace. Nothing here can write
|
|
339
|
+
* outside the workspace: every path is re-validated, and every target lives
|
|
340
|
+
* under the project's own `.scalix/` directory or the root MCP config.
|
|
341
|
+
*/
|
|
342
|
+
export async function installMarketplaceListing(detail, options = {}) {
|
|
343
|
+
const cwd = options.cwd ?? process.cwd();
|
|
344
|
+
if (!detail.payload) {
|
|
345
|
+
return { success: false, message: '', paths: [], error: 'listing has no installable payload' };
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
switch (detail.payload.kind) {
|
|
349
|
+
case 'mcp':
|
|
350
|
+
return await installMcpEntry(detail.payload, { ...options, cwd });
|
|
351
|
+
case 'skill': {
|
|
352
|
+
if (!detail.payload.files.some(f => f.path === 'SKILL.md')) {
|
|
353
|
+
return { success: false, message: '', paths: [], error: 'skill payload has no SKILL.md' };
|
|
354
|
+
}
|
|
355
|
+
const dir = path.join(cwd, '.scalix', 'skills', detail.payload.name);
|
|
356
|
+
const paths = await writeListingFiles(detail.payload.files, dir, cwd);
|
|
357
|
+
return { success: true, message: `Installed skill ${detail.payload.name} (${paths.join(', ')})`, paths };
|
|
358
|
+
}
|
|
359
|
+
case 'rule':
|
|
360
|
+
case 'subagent': {
|
|
361
|
+
const subdir = detail.payload.kind === 'rule' ? 'rules' : 'agents';
|
|
362
|
+
const dir = path.join(cwd, '.scalix', subdir);
|
|
363
|
+
await fs.mkdir(dir, { recursive: true });
|
|
364
|
+
const paths = await writeListingFiles([{ path: detail.payload.fileName, content: detail.payload.content }], dir, cwd);
|
|
365
|
+
return {
|
|
366
|
+
success: true,
|
|
367
|
+
message: `Installed ${detail.payload.kind} ${detail.payload.fileName} (${paths.join(', ')})`,
|
|
368
|
+
paths,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
return { success: false, message: '', paths: [], error: err?.message ?? String(err) };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** Convenience: fetch a listing, install it, and report telemetry (best effort). */
|
|
378
|
+
export async function installFromMarketplace(listingId, options = {}) {
|
|
379
|
+
const detail = await getMarketplaceListing(listingId);
|
|
380
|
+
const result = await installMarketplaceListing(detail, options);
|
|
381
|
+
if (result.success)
|
|
382
|
+
await reportMarketplaceInstall(detail);
|
|
383
|
+
return result;
|
|
384
|
+
}
|
|
385
|
+
export { KINDS as MARKETPLACE_KINDS, TRUSTS as MARKETPLACE_TRUSTS };
|
|
386
|
+
/** True when the workspace already has an MCP server under this label. */
|
|
387
|
+
export function hasMcpLabel(cwd, label) {
|
|
388
|
+
const configPath = path.resolve(cwd, MCP_CONFIG);
|
|
389
|
+
if (!fsSync.existsSync(configPath))
|
|
390
|
+
return false;
|
|
391
|
+
try {
|
|
392
|
+
const parsed = normalizeMcpConfig(JSON.parse(fsSync.readFileSync(configPath, 'utf-8')));
|
|
393
|
+
if (!Array.isArray(parsed?.mcpServers))
|
|
394
|
+
return false;
|
|
395
|
+
return parsed.mcpServers.some((s) => s?.serverLabel === label);
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
}
|
package/dist/mcp.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { existsSync, readFileSync } from 'node:fs';
|
|
23
23
|
import { resolve } from 'node:path';
|
|
24
|
-
import
|
|
24
|
+
import { resolveLookupAddresses, pinnedFetch } from './secure-fetch.js';
|
|
25
25
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
26
26
|
import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
27
27
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
@@ -109,7 +109,7 @@ export function loadMcpConfig(configPath = 'mcp-services.json') {
|
|
|
109
109
|
// Server READMEs ship example URLs; connecting to them just produces a
|
|
110
110
|
// confusing timeout, so drop them before we ever dial.
|
|
111
111
|
if (url.includes('example.com') || url.includes('your-') || url.includes('placeholder')) {
|
|
112
|
-
logger.warn(`Skipping MCP "${s.serverLabel}": placeholder URL detected`, { url: s.serverUrl });
|
|
112
|
+
logger.warn(`Skipping MCP "${s.serverLabel}": placeholder URL detected`, { url: redactUrl(s.serverUrl) });
|
|
113
113
|
return false;
|
|
114
114
|
}
|
|
115
115
|
return true;
|
|
@@ -148,22 +148,38 @@ export function validateMcpUrl(rawUrl) {
|
|
|
148
148
|
}
|
|
149
149
|
return null;
|
|
150
150
|
}
|
|
151
|
-
/**
|
|
152
|
-
|
|
151
|
+
/**
|
|
152
|
+
* URL safe for logs: strip userinfo (`https://user:token@host` →
|
|
153
|
+
* `https://host`) so credentials embedded in a serverUrl never reach the log.
|
|
154
|
+
*/
|
|
155
|
+
function redactUrl(rawUrl) {
|
|
153
156
|
try {
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return 'hostname resolves to a private network address';
|
|
161
|
-
}
|
|
157
|
+
const url = new URL(rawUrl);
|
|
158
|
+
if (!url.username && !url.password)
|
|
159
|
+
return rawUrl;
|
|
160
|
+
url.username = '';
|
|
161
|
+
url.password = '';
|
|
162
|
+
return url.href;
|
|
162
163
|
}
|
|
163
164
|
catch {
|
|
164
|
-
|
|
165
|
+
return rawUrl;
|
|
165
166
|
}
|
|
166
|
-
|
|
167
|
+
}
|
|
168
|
+
/** Post-DNS half of the check: defeats a public name resolving inward.
|
|
169
|
+
* The validated addresses are returned so the dial can be pinned to them —
|
|
170
|
+
* validating one resolution and connecting on another is the rebinding hole
|
|
171
|
+
* itself (see secure-fetch.ts). */
|
|
172
|
+
async function resolvesPrivate(hostname) {
|
|
173
|
+
const addresses = await resolveLookupAddresses(hostname);
|
|
174
|
+
// isPrivateHost rather than isPrivateIp: for a literal address the two
|
|
175
|
+
// agree, but only the former honours security.allowedPrivateHosts, so an
|
|
176
|
+
// operator who deliberately allowed a private MCP host is not blocked one
|
|
177
|
+
// step later by the rebinding check they never asked for.
|
|
178
|
+
if (addresses.length > 0 && addresses.some((a) => isPrivateHost(a.address))) {
|
|
179
|
+
return { blocked: 'hostname resolves to a private network address', addresses: [] };
|
|
180
|
+
}
|
|
181
|
+
// A DNS failure is the dial's problem to report, not a reason to block.
|
|
182
|
+
return { blocked: null, addresses };
|
|
167
183
|
}
|
|
168
184
|
/**
|
|
169
185
|
* The fetch both remote transports run on.
|
|
@@ -178,10 +194,13 @@ async function mcpFetch(input, init) {
|
|
|
178
194
|
let current = new URL(typeof input === 'string' ? input : input.toString());
|
|
179
195
|
let headers = new Headers(init?.headers);
|
|
180
196
|
for (let hop = 0;; hop++) {
|
|
181
|
-
const
|
|
197
|
+
const urlBlocked = validateMcpUrl(current.href);
|
|
198
|
+
if (urlBlocked)
|
|
199
|
+
throw new Error(`Blocked MCP request: ${urlBlocked}`);
|
|
200
|
+
const { blocked, addresses } = await resolvesPrivate(current.hostname);
|
|
182
201
|
if (blocked)
|
|
183
202
|
throw new Error(`Blocked MCP request: ${blocked}`);
|
|
184
|
-
const response = await
|
|
203
|
+
const response = await pinnedFetch(current, { ...init, headers, redirect: 'manual' }, addresses);
|
|
185
204
|
if (response.status < 300 || response.status >= 400)
|
|
186
205
|
return response;
|
|
187
206
|
const location = response.headers.get('location');
|
|
@@ -190,12 +209,20 @@ async function mcpFetch(input, init) {
|
|
|
190
209
|
if (hop >= MAX_MCP_REDIRECTS) {
|
|
191
210
|
throw new Error(`Blocked MCP request: too many redirects (>${MAX_MCP_REDIRECTS})`);
|
|
192
211
|
}
|
|
212
|
+
// 301/302/303 on a request with a body switch to GET and drop the body per
|
|
213
|
+
// the fetch spec; 307/308 keep method and body. Replaying a POST body to
|
|
214
|
+
// the redirect target would leak it cross-origin.
|
|
215
|
+
let nextInit = init;
|
|
216
|
+
if ((response.status === 301 || response.status === 302 || response.status === 303) && init?.method && init.method.toUpperCase() !== 'GET' && init.method.toUpperCase() !== 'HEAD') {
|
|
217
|
+
nextInit = { ...init, method: 'GET', body: undefined };
|
|
218
|
+
}
|
|
193
219
|
const next = new URL(location, current);
|
|
194
220
|
if (next.origin !== current.origin) {
|
|
195
221
|
headers = new Headers(headers);
|
|
196
222
|
headers.delete('authorization');
|
|
197
223
|
}
|
|
198
224
|
current = next;
|
|
225
|
+
init = nextInit;
|
|
199
226
|
}
|
|
200
227
|
}
|
|
201
228
|
function resolveAuthorization(config) {
|
|
@@ -371,7 +398,7 @@ export class McpHub {
|
|
|
371
398
|
const error = 'MCP servers are only started in a trusted workspace';
|
|
372
399
|
logger.warn(`Skipping MCP "${label}": ${error}`, {
|
|
373
400
|
transport: kindOf(),
|
|
374
|
-
target: isStdioService(config) ? config.command : config.serverUrl,
|
|
401
|
+
target: isStdioService(config) ? config.command : redactUrl(config.serverUrl),
|
|
375
402
|
});
|
|
376
403
|
return { label, transport: kindOf(), state: 'skipped', toolCount: 0, error };
|
|
377
404
|
}
|
|
@@ -379,7 +406,7 @@ export class McpHub {
|
|
|
379
406
|
const blocked = validateMcpUrl(config.serverUrl);
|
|
380
407
|
if (blocked) {
|
|
381
408
|
const error = `refusing to connect: ${blocked}`;
|
|
382
|
-
logger.warn(`Skipping MCP "${label}": ${error}`, { url: config.serverUrl });
|
|
409
|
+
logger.warn(`Skipping MCP "${label}": ${error}`, { url: redactUrl(config.serverUrl) });
|
|
383
410
|
return { label, transport: kindOf(), state: 'skipped', toolCount: 0, error };
|
|
384
411
|
}
|
|
385
412
|
}
|
package/dist/providers/index.js
CHANGED
|
@@ -4,26 +4,43 @@
|
|
|
4
4
|
// user's own endpoint + key (no hardcoded third-party defaults anywhere).
|
|
5
5
|
// The agent consumes a client (ScalixClient-shaped) plus a capability set that
|
|
6
6
|
// scopes the Scalix-platform workarounds to the scalix provider only.
|
|
7
|
+
//
|
|
8
|
+
// A provider KEY (e.g. `scalix`, `openai`, `novita`, `local-ollama`) names a
|
|
9
|
+
// configured registry entry; an ADAPTER (`scalix` | `openai`) picks the wire
|
|
10
|
+
// dialect and, with it, the capability set. Every non-scalix key speaks the
|
|
11
|
+
// OpenAI-compatible wire, so new BYOK providers need no code — only config.
|
|
7
12
|
import { ScalixClient } from '../api-client.js';
|
|
8
13
|
import { OpenAICompatClient } from './openai.js';
|
|
14
|
+
/** Built-in provider keys. Any other key is a user-defined BYOK provider. */
|
|
9
15
|
export const PROVIDER_IDS = ['scalix', 'openai'];
|
|
16
|
+
const PROVIDER_KEY_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
|
17
|
+
/** A syntactically usable provider key. Unknown keys are allowed on purpose:
|
|
18
|
+
* the registry (config.providers) is the source of truth for what exists. */
|
|
10
19
|
export function isProviderId(v) {
|
|
20
|
+
return typeof v === 'string' && PROVIDER_KEY_RE.test(v);
|
|
21
|
+
}
|
|
22
|
+
export function isBuiltInProviderId(v) {
|
|
11
23
|
return typeof v === 'string' && PROVIDER_IDS.includes(v);
|
|
12
24
|
}
|
|
25
|
+
/** Resolve the wire dialect for a provider key. Scalix is the only special
|
|
26
|
+
* case; everything else is OpenAI-compatible. */
|
|
27
|
+
export function adapterFor(provider) {
|
|
28
|
+
return provider === 'scalix' ? 'scalix' : 'openai';
|
|
29
|
+
}
|
|
13
30
|
const CAPABILITIES = {
|
|
14
31
|
scalix: { streamingToolCalls: false, maxTokensPolicy: 'clamped', platformTools: true },
|
|
15
32
|
openai: { streamingToolCalls: true, maxTokensPolicy: 'explicit-only', platformTools: false },
|
|
16
33
|
};
|
|
17
34
|
export function capabilitiesFor(provider) {
|
|
18
|
-
return CAPABILITIES[provider];
|
|
35
|
+
return CAPABILITIES[adapterFor(provider)];
|
|
19
36
|
}
|
|
20
37
|
/** Build the chat client for a provider. baseUrl semantics differ:
|
|
21
38
|
* - scalix: API host root (gateway nests /v1/ai) — DEFAULT_BASE_URL applies upstream;
|
|
22
|
-
* - openai: the endpoint root of any OpenAI-compatible server, with
|
|
23
|
-
* without its /v1 suffix (api.openai.com/v1, openrouter.ai/api/v1,
|
|
39
|
+
* - openai-compatible: the endpoint root of any OpenAI-compatible server, with
|
|
40
|
+
* or without its /v1 suffix (api.openai.com/v1, openrouter.ai/api/v1,
|
|
24
41
|
* localhost:11434, ...). Required — there is no default third-party URL. */
|
|
25
42
|
export function createChatClient(provider, baseUrl, apiKey) {
|
|
26
|
-
if (provider === 'openai') {
|
|
43
|
+
if (adapterFor(provider) === 'openai') {
|
|
27
44
|
return new OpenAICompatClient(baseUrl, apiKey);
|
|
28
45
|
}
|
|
29
46
|
return new ScalixClient(baseUrl, apiKey);
|