scalix 1.5.1 → 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/dist/keychain.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execFile } from 'node:child_process';
1
+ import { execFile, spawn } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
@@ -159,6 +159,30 @@ export async function getApiKeyFromKeychain(account = ACCOUNT) {
159
159
  }
160
160
  return null;
161
161
  }
162
+ /**
163
+ * `security add-generic-password` with the secret delivered over stdin.
164
+ *
165
+ * Returns false rather than throwing; the caller treats that as "no keychain"
166
+ * and falls back to the config file. There is deliberately no argv fallback —
167
+ * that is the thing this exists to avoid.
168
+ */
169
+ function addGenericPasswordViaStdin(apiKey, account) {
170
+ return new Promise((resolve) => {
171
+ const child = spawn('security', [
172
+ 'add-generic-password',
173
+ '-s', SERVICE,
174
+ '-a', account,
175
+ '-U',
176
+ '-w',
177
+ ], { stdio: ['pipe', 'ignore', 'ignore'] });
178
+ const timer = setTimeout(() => child.kill('SIGKILL'), KEYCHAIN_TIMEOUT_MS);
179
+ child.once('error', () => { clearTimeout(timer); resolve(false); });
180
+ child.once('close', (code) => { clearTimeout(timer); resolve(code === 0); });
181
+ // EPIPE if security exits before reading — already covered by the exit code.
182
+ child.stdin.on('error', () => { });
183
+ child.stdin.end(`${apiKey}\n${apiKey}\n`);
184
+ });
185
+ }
162
186
  export async function setApiKeyInKeychain(apiKey, account = ACCOUNT) {
163
187
  if (keychainDisabled())
164
188
  return false;
@@ -178,14 +202,15 @@ export async function setApiKeyInKeychain(apiKey, account = ACCOUNT) {
178
202
  catch {
179
203
  // entry didn't exist
180
204
  }
181
- await exec('security', [
182
- 'add-generic-password',
183
- '-s', SERVICE,
184
- '-a', account,
185
- '-w', apiKey,
186
- '-U',
187
- ]);
188
- return true;
205
+ // `-w <key>` puts the API key in this process's argv, where every other
206
+ // user on the machine can read it out of `ps -ef` for as long as the call
207
+ // runs. `security` reads the secret from stdin when -w is given no value,
208
+ // so hand it over that way instead. Some releases prompt twice (enter,
209
+ // then confirm); writing the key on two lines satisfies both, and a
210
+ // single-read build simply ignores the trailing line.
211
+ if (await addGenericPasswordViaStdin(apiKey, account))
212
+ return true;
213
+ return false;
189
214
  }
190
215
  if (await isLinuxWithSecretTool()) {
191
216
  const child = exec('secret-tool', [
@@ -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
+ }