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.
@@ -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 dns from 'node:dns';
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
- /** Post-DNS half of the check: defeats a public name resolving inward. */
152
- async function resolvesPrivate(hostname) {
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 addresses = await dns.promises.lookup(hostname, { all: true });
155
- // isPrivateHost rather than isPrivateIp: for a literal address the two
156
- // agree, but only the former honours security.allowedPrivateHosts, so an
157
- // operator who deliberately allowed a private MCP host is not blocked one
158
- // step later by the rebinding check they never asked for.
159
- if (addresses.some((a) => isPrivateHost(a.address))) {
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
- // A DNS failure is the dial's problem to report, not a reason to block.
165
+ return rawUrl;
165
166
  }
166
- return null;
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 blocked = validateMcpUrl(current.href) ?? (await resolvesPrivate(current.hostname));
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 fetch(current.href, { ...init, headers, redirect: 'manual' });
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
  }
@@ -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 or
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);