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,117 @@
|
|
|
1
|
+
// Connection-pinned fetch.
|
|
2
|
+
//
|
|
3
|
+
// The SSRF checks in tools-web.ts and mcp.ts resolve the hostname, decide the
|
|
4
|
+
// address is public, and then let fetch() resolve the name AGAIN on its own.
|
|
5
|
+
// Between the two lookups an attacker who controls DNS can flip the answer
|
|
6
|
+
// (DNS rebinding): the validation sees a public address, the connection lands
|
|
7
|
+
// on 127.0.0.1 / 169.254.169.254 / anything internal, with whatever
|
|
8
|
+
// Authorization header the caller attached. Validating and connecting are two
|
|
9
|
+
// different resolutions of the same name, so the check protects nothing.
|
|
10
|
+
//
|
|
11
|
+
// The fix is to make them the SAME resolution: resolve once, keep the
|
|
12
|
+
// addresses, and hand them to the connection as a pinned `lookup` so the
|
|
13
|
+
// socket can only ever reach an address that was actually validated.
|
|
14
|
+
//
|
|
15
|
+
// node:http/https are used rather than global fetch for exactly one reason —
|
|
16
|
+
// fetch (undici) exposes no way to pin DNS. The hostname is left untouched, so
|
|
17
|
+
// SNI, the Host header and the certificate's hostname check all still apply to
|
|
18
|
+
// the real name; only the address it dials is constrained.
|
|
19
|
+
import dns from 'node:dns';
|
|
20
|
+
import http from 'node:http';
|
|
21
|
+
import https from 'node:https';
|
|
22
|
+
import zlib from 'node:zlib';
|
|
23
|
+
import { Readable } from 'node:stream';
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a hostname for pinning. Returns [] when the name cannot be resolved
|
|
26
|
+
* (or the caller deliberately skipped pinning) — the request then falls back
|
|
27
|
+
* to ordinary fetch, whose own failure is the honest outcome.
|
|
28
|
+
*/
|
|
29
|
+
export async function resolveLookupAddresses(hostname) {
|
|
30
|
+
try {
|
|
31
|
+
const addresses = await dns.promises.lookup(hostname, { all: true });
|
|
32
|
+
return addresses.length > 0 ? addresses : [];
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function pinnedLookup(addresses) {
|
|
39
|
+
const list = addresses.map((a) => ({ address: a.address, family: a.family }));
|
|
40
|
+
return function lookup(_hostname, options, callback) {
|
|
41
|
+
if (options && options.all) {
|
|
42
|
+
callback(null, list);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
const first = list[0];
|
|
46
|
+
callback(null, first.address, first.family);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Decode a compressed HTTP stream so callers see the same bytes fetch() would. */
|
|
51
|
+
function decodeBody(res) {
|
|
52
|
+
const encoding = res.headers['content-encoding'];
|
|
53
|
+
if (encoding === 'gzip')
|
|
54
|
+
return res.pipe(zlib.createGunzip());
|
|
55
|
+
if (encoding === 'deflate')
|
|
56
|
+
return res.pipe(zlib.createInflate());
|
|
57
|
+
if (encoding === 'br')
|
|
58
|
+
return res.pipe(zlib.createBrotliDecompress());
|
|
59
|
+
return res;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* fetch() with the connection pinned to pre-validated addresses.
|
|
63
|
+
*
|
|
64
|
+
* Drop-in for the call sites in tools-web.ts and mcp.ts: same RequestInit
|
|
65
|
+
* shape, same Response contract (streaming body included, so long-lived SSE
|
|
66
|
+
* responses such as MCP's keep working).
|
|
67
|
+
*/
|
|
68
|
+
export async function pinnedFetch(url, init, addresses) {
|
|
69
|
+
if (addresses.length === 0) {
|
|
70
|
+
// Nothing was validated to pin to; the caller decided this is allowed.
|
|
71
|
+
return fetch(url, init);
|
|
72
|
+
}
|
|
73
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
74
|
+
const headers = new Headers(init.headers);
|
|
75
|
+
const method = (init.method || 'GET').toUpperCase();
|
|
76
|
+
const body = init.body;
|
|
77
|
+
const nodeResponse = await new Promise((resolve, reject) => {
|
|
78
|
+
const request = transport.request(url, {
|
|
79
|
+
method,
|
|
80
|
+
// The whole point: dial only what we validated.
|
|
81
|
+
lookup: pinnedLookup(addresses),
|
|
82
|
+
// Fresh connection per request — a pooled socket is keyed by host:port
|
|
83
|
+
// and would carry no lookup of its own.
|
|
84
|
+
agent: false,
|
|
85
|
+
signal: init.signal ?? undefined,
|
|
86
|
+
headers: Object.fromEntries(headers.entries()),
|
|
87
|
+
}, resolve);
|
|
88
|
+
request.on('error', reject);
|
|
89
|
+
if (body !== undefined && body !== null) {
|
|
90
|
+
if (typeof body === 'string')
|
|
91
|
+
request.write(body);
|
|
92
|
+
else if (body instanceof Uint8Array)
|
|
93
|
+
request.write(body);
|
|
94
|
+
else if (body instanceof URLSearchParams)
|
|
95
|
+
request.write(body.toString());
|
|
96
|
+
else
|
|
97
|
+
reject(new Error(`pinnedFetch: unsupported request body type (${typeof body})`));
|
|
98
|
+
}
|
|
99
|
+
request.end();
|
|
100
|
+
});
|
|
101
|
+
const responseHeaders = new Headers();
|
|
102
|
+
for (const [key, value] of Object.entries(nodeResponse.headers)) {
|
|
103
|
+
if (value === undefined)
|
|
104
|
+
continue;
|
|
105
|
+
responseHeaders.set(key, Array.isArray(value) ? value.join(', ') : value);
|
|
106
|
+
}
|
|
107
|
+
// A decoded stream's length no longer matches the compressed original, and a
|
|
108
|
+
// stale content-length makes readers stop early.
|
|
109
|
+
const decoded = nodeResponse.headers['content-encoding'] !== undefined;
|
|
110
|
+
if (decoded)
|
|
111
|
+
responseHeaders.delete('content-length');
|
|
112
|
+
return new Response(Readable.toWeb(decodeBody(nodeResponse)), {
|
|
113
|
+
status: nodeResponse.statusCode ?? 502,
|
|
114
|
+
statusText: nodeResponse.statusMessage || '',
|
|
115
|
+
headers: responseHeaders,
|
|
116
|
+
});
|
|
117
|
+
}
|
package/dist/settings.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { setSecurityConfig, setScopedPermissionRules } from './sandbox.js';
|
|
4
|
+
import { COMMAND_SANDBOX_MODES } from './command-sandbox.js';
|
|
4
5
|
import { DEFAULT_BASE_URL, DEFAULT_MODELS, AGENT_DEFAULTS, CONTEXT_DEFAULTS, WEB_DEFAULTS, TOOL_DEFAULTS, SUB_AGENT_DEFAULTS, MULTI_AGENT_DEFAULTS, USER_CONFIG_DIR, registerUserModels, setNetworkSecurityOverrides, normalizeModelId, } from './constants.js';
|
|
5
6
|
import { getApiKeyFromKeychain, setApiKeyInKeychain, isKeychainAvailable } from './keychain.js';
|
|
6
7
|
import { isProviderId } from './providers/index.js';
|
|
@@ -165,22 +166,41 @@ function validateConfig(parsed) {
|
|
|
165
166
|
if (typeof parsed.disableUpdateCheck === 'boolean') {
|
|
166
167
|
safe.disableUpdateCheck = parsed.disableUpdateCheck;
|
|
167
168
|
}
|
|
169
|
+
// OS-level command sandbox (command-sandbox.ts). Opt-in: absent or invalid
|
|
170
|
+
// values resolve to 'off' so existing installs never change behaviour.
|
|
171
|
+
if (typeof parsed.commandSandbox === 'string' && COMMAND_SANDBOX_MODES.includes(parsed.commandSandbox)) {
|
|
172
|
+
safe.commandSandbox = parsed.commandSandbox;
|
|
173
|
+
}
|
|
168
174
|
// BYOK (validateConfig drops unknown keys, so these MUST be whitelisted here
|
|
169
175
|
// or they vanish on the next saveConfig — spec §4 migration gotcha #1).
|
|
170
176
|
if (isProviderId(parsed.provider)) {
|
|
171
177
|
safe.provider = parsed.provider;
|
|
172
178
|
}
|
|
173
179
|
if (parsed.providers && typeof parsed.providers === 'object' && !Array.isArray(parsed.providers)) {
|
|
174
|
-
const
|
|
175
|
-
|
|
180
|
+
const registry = {};
|
|
181
|
+
for (const [key, raw] of Object.entries(parsed.providers)) {
|
|
182
|
+
if (!isProviderId(key))
|
|
183
|
+
continue;
|
|
184
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
185
|
+
continue;
|
|
186
|
+
const po = raw;
|
|
176
187
|
const creds = {};
|
|
177
188
|
if (typeof po.apiKey === 'string')
|
|
178
189
|
creds.apiKey = po.apiKey;
|
|
179
190
|
if (typeof po.baseUrl === 'string' && po.baseUrl.length > 0)
|
|
180
191
|
creds.baseUrl = po.baseUrl;
|
|
192
|
+
if (typeof po.name === 'string' && po.name.trim())
|
|
193
|
+
creds.name = po.name.trim();
|
|
194
|
+
if (typeof po.enabled === 'boolean')
|
|
195
|
+
creds.enabled = po.enabled;
|
|
181
196
|
if (Object.keys(creds).length > 0)
|
|
182
|
-
|
|
197
|
+
registry[key] = creds;
|
|
183
198
|
}
|
|
199
|
+
if (Object.keys(registry).length > 0)
|
|
200
|
+
safe.providers = registry;
|
|
201
|
+
}
|
|
202
|
+
if (isStringArray(parsed.disabledProviders)) {
|
|
203
|
+
safe.disabledProviders = parsed.disabledProviders.filter((p) => isProviderId(p));
|
|
184
204
|
}
|
|
185
205
|
return safe;
|
|
186
206
|
}
|
|
@@ -286,6 +306,33 @@ function validateScopedPermissions(raw) {
|
|
|
286
306
|
}
|
|
287
307
|
return valid;
|
|
288
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Security fields a *project* file may set. Project-local settings ship inside
|
|
311
|
+
* the repo, so they are attacker-controllable; only fields that tighten the
|
|
312
|
+
* posture (more blocking, more read-only) may come from them. Anything that
|
|
313
|
+
* loosens the sandbox — reclassifying tools, disabling blocklist/injection
|
|
314
|
+
* patterns, allowing private hosts or env leakage — stays a user-level or
|
|
315
|
+
* managed-settings decision.
|
|
316
|
+
*/
|
|
317
|
+
const PROJECT_SAFE_SECURITY_FIELDS = new Set([
|
|
318
|
+
'additionalBlockedCommands',
|
|
319
|
+
'additionalBlockedHosts',
|
|
320
|
+
'additionalInjectionPatterns',
|
|
321
|
+
'additionalReadOnlyTools',
|
|
322
|
+
'additionalSensitiveEnvPatterns',
|
|
323
|
+
'maxImageSize',
|
|
324
|
+
]);
|
|
325
|
+
function filterProjectSafeSecurity(sec) {
|
|
326
|
+
if (!sec)
|
|
327
|
+
return undefined;
|
|
328
|
+
const safe = {};
|
|
329
|
+
for (const key of PROJECT_SAFE_SECURITY_FIELDS) {
|
|
330
|
+
const val = sec[key];
|
|
331
|
+
if (val !== undefined)
|
|
332
|
+
safe[key] = val;
|
|
333
|
+
}
|
|
334
|
+
return safe;
|
|
335
|
+
}
|
|
289
336
|
async function loadLocalOverrides(config) {
|
|
290
337
|
// Project-local settings are attacker-controllable (they ship in the repo), so
|
|
291
338
|
// only honor security-relevant overrides in a trusted workspace, and always
|
|
@@ -305,19 +352,36 @@ async function loadLocalOverrides(config) {
|
|
|
305
352
|
const validated = validateConfig(overrides);
|
|
306
353
|
if (validated.models)
|
|
307
354
|
config.models = validated.models;
|
|
308
|
-
|
|
355
|
+
// A repo may only TIGHTEN the permission mode (plan). 'auto'/'yolo' from
|
|
356
|
+
// a project file would silently disable confirmation prompts after a
|
|
357
|
+
// single "Trust" click — those stay user/managed-level decisions.
|
|
358
|
+
if (validated.permissionMode === 'plan') {
|
|
309
359
|
config.permissionMode = validated.permissionMode;
|
|
360
|
+
}
|
|
361
|
+
else if (validated.permissionMode === 'auto' || validated.permissionMode === 'yolo') {
|
|
362
|
+
process.stderr.write(`Project settings tried to set permissionMode "${validated.permissionMode}" — ignored (escalation from project files is not allowed; set it via \`scalix\` config or SCALIX_PERMISSION_MODE).\n`);
|
|
363
|
+
}
|
|
310
364
|
if (validated.thinkingMode)
|
|
311
365
|
config.thinkingMode = validated.thinkingMode;
|
|
312
366
|
if (validated.limits)
|
|
313
367
|
config.limits = { ...config.limits, ...validated.limits };
|
|
314
|
-
|
|
315
|
-
|
|
368
|
+
const projectSecurity = filterProjectSafeSecurity(validated.security);
|
|
369
|
+
if (projectSecurity)
|
|
370
|
+
config.security = { ...config.security, ...projectSecurity };
|
|
316
371
|
if (validated.customModels)
|
|
317
372
|
config.customModels = { ...config.customModels, ...validated.customModels };
|
|
373
|
+
// Scoped permissions from a project file may only DENY. An 'allow' rule
|
|
374
|
+
// in the repo would pre-approve exactly the tools the repo author chose.
|
|
318
375
|
const scoped = validateScopedPermissions(overrides.scopedPermissions);
|
|
319
|
-
if (scoped)
|
|
320
|
-
|
|
376
|
+
if (scoped) {
|
|
377
|
+
const denyOnly = scoped.filter(r => r.action === 'deny');
|
|
378
|
+
if (denyOnly.length > 0) {
|
|
379
|
+
config.scopedPermissions = denyOnly;
|
|
380
|
+
}
|
|
381
|
+
else if (scoped.length > 0) {
|
|
382
|
+
process.stderr.write('Project settings tried to add scoped permission "allow" rules — ignored (project files may only add deny rules).\n');
|
|
383
|
+
}
|
|
384
|
+
}
|
|
321
385
|
break;
|
|
322
386
|
}
|
|
323
387
|
catch { }
|
|
@@ -332,16 +396,21 @@ async function loadManagedSettings(config) {
|
|
|
332
396
|
try {
|
|
333
397
|
const raw = await fs.readFile(managedPath, 'utf-8');
|
|
334
398
|
const managed = JSON.parse(raw);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if (
|
|
399
|
+
// Managed settings are the highest-precedence trust anchor — validate
|
|
400
|
+
// them at least as strictly as user config so a malformed policy file
|
|
401
|
+
// can't corrupt permissionMode/security with unvalidated values.
|
|
402
|
+
const validated = validateConfig(managed);
|
|
403
|
+
if (validated.permissionMode)
|
|
404
|
+
config.permissionMode = validated.permissionMode;
|
|
405
|
+
if (validated.security)
|
|
406
|
+
config.security = { ...config.security, ...validated.security };
|
|
407
|
+
const scoped = validateScopedPermissions(managed.scopedPermissions);
|
|
408
|
+
if (scoped)
|
|
340
409
|
config.scopedPermissions = [
|
|
341
410
|
...(config.scopedPermissions || []),
|
|
342
|
-
...
|
|
411
|
+
...scoped,
|
|
343
412
|
];
|
|
344
|
-
if (managed.disableUpdateCheck
|
|
413
|
+
if (typeof managed.disableUpdateCheck === 'boolean')
|
|
345
414
|
config.disableUpdateCheck = managed.disableUpdateCheck;
|
|
346
415
|
break;
|
|
347
416
|
}
|
|
@@ -361,11 +430,15 @@ export async function loadConfig() {
|
|
|
361
430
|
if (keychainKey)
|
|
362
431
|
config.apiKey = keychainKey;
|
|
363
432
|
}
|
|
364
|
-
// Per-provider keychain
|
|
365
|
-
if (config.providers
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
433
|
+
// Per-provider keychain slots (BYOK): each provider key has its own account.
|
|
434
|
+
if (config.providers) {
|
|
435
|
+
for (const [key, creds] of Object.entries(config.providers)) {
|
|
436
|
+
if (creds.apiKey)
|
|
437
|
+
continue;
|
|
438
|
+
const k = await getApiKeyFromKeychain(providerKeychainAccount(key)).catch(() => null);
|
|
439
|
+
if (k)
|
|
440
|
+
creds.apiKey = k;
|
|
441
|
+
}
|
|
369
442
|
}
|
|
370
443
|
// Apply local overrides (project-level, gitignored)
|
|
371
444
|
config = await loadLocalOverrides(config);
|
|
@@ -422,17 +495,19 @@ export async function saveConfig(config) {
|
|
|
422
495
|
else
|
|
423
496
|
plaintextSecret = true;
|
|
424
497
|
}
|
|
425
|
-
if (config.providers
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
498
|
+
if (config.providers) {
|
|
499
|
+
const nextProviders = {};
|
|
500
|
+
for (const [key, creds] of Object.entries(config.providers)) {
|
|
501
|
+
nextProviders[key] = { ...creds };
|
|
502
|
+
if (creds.apiKey) {
|
|
503
|
+
const stored = await setApiKeyInKeychain(creds.apiKey, providerKeychainAccount(key)).catch(() => false);
|
|
504
|
+
if (stored)
|
|
505
|
+
nextProviders[key].apiKey = '';
|
|
506
|
+
else
|
|
507
|
+
plaintextSecret = true;
|
|
508
|
+
}
|
|
435
509
|
}
|
|
510
|
+
configToWrite.providers = nextProviders;
|
|
436
511
|
}
|
|
437
512
|
}
|
|
438
513
|
await fs.writeFile(getConfigFile(), JSON.stringify(configToWrite, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
@@ -447,20 +522,85 @@ export function isConfigured(config) {
|
|
|
447
522
|
}
|
|
448
523
|
// ─── BYOK provider resolution ───
|
|
449
524
|
export const OPENAI_KEYCHAIN_ACCOUNT = 'api-key-openai';
|
|
525
|
+
/** Keychain account for a provider key. Scalix uses the legacy default account;
|
|
526
|
+
* every other provider gets its own slot so keys never collide. */
|
|
527
|
+
export function providerKeychainAccount(provider) {
|
|
528
|
+
return provider === 'scalix' ? undefined : `api-key-${provider}`;
|
|
529
|
+
}
|
|
530
|
+
/** Every provider key that should appear in the picker: the built-in Scalix
|
|
531
|
+
* entry plus each configured registry entry, minus `disabledProviders` and
|
|
532
|
+
* entries disabled individually. */
|
|
533
|
+
export function listProviderKeys(config) {
|
|
534
|
+
const disabled = new Set(config.disabledProviders || []);
|
|
535
|
+
const keys = ['scalix'];
|
|
536
|
+
for (const [key, creds] of Object.entries(config.providers || {})) {
|
|
537
|
+
if (key === 'scalix')
|
|
538
|
+
continue;
|
|
539
|
+
if (creds.enabled === false)
|
|
540
|
+
continue;
|
|
541
|
+
if (disabled.has(key))
|
|
542
|
+
continue;
|
|
543
|
+
keys.push(key);
|
|
544
|
+
}
|
|
545
|
+
return keys;
|
|
546
|
+
}
|
|
547
|
+
/** Resolve one provider key from config only (no env override). Used for
|
|
548
|
+
* explicit per-session overrides, where the caller's choice must win over the
|
|
549
|
+
* process-wide `SCALIX_PROVIDER`. */
|
|
550
|
+
export function resolveProviderByKey(config, provider) {
|
|
551
|
+
if (provider !== 'scalix') {
|
|
552
|
+
const creds = config.providers?.[provider] ?? {};
|
|
553
|
+
return {
|
|
554
|
+
provider,
|
|
555
|
+
baseUrl: creds.baseUrl || '',
|
|
556
|
+
apiKey: creds.apiKey || '',
|
|
557
|
+
cloudBaseUrl: config.baseUrl,
|
|
558
|
+
cloudApiKey: config.apiKey || '',
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
provider: 'scalix',
|
|
563
|
+
baseUrl: config.baseUrl,
|
|
564
|
+
apiKey: config.apiKey,
|
|
565
|
+
cloudBaseUrl: config.baseUrl,
|
|
566
|
+
cloudApiKey: config.apiKey,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
/** Picker-facing provider list. Never includes keys — only identity/status. */
|
|
570
|
+
export function summarizeProviders(config) {
|
|
571
|
+
const out = [];
|
|
572
|
+
for (const key of listProviderKeys(config)) {
|
|
573
|
+
if (key === 'scalix') {
|
|
574
|
+
out.push({ id: 'scalix', name: 'Scalix AI', adapter: 'scalix', configured: !!config.apiKey, builtIn: true });
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
const creds = config.providers?.[key] ?? {};
|
|
578
|
+
out.push({
|
|
579
|
+
id: key,
|
|
580
|
+
name: creds.name || key,
|
|
581
|
+
adapter: 'openai',
|
|
582
|
+
configured: !!(creds.baseUrl && creds.apiKey),
|
|
583
|
+
builtIn: false,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
return out;
|
|
587
|
+
}
|
|
450
588
|
/**
|
|
451
589
|
* Resolve which provider + credentials this session runs on.
|
|
452
590
|
*
|
|
453
591
|
* Precedence (mirrors the rest of config): env → config. `SCALIX_PROVIDER`
|
|
454
|
-
* selects the provider; for
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
592
|
+
* selects the provider key (built-in or registry); for any non-scalix provider
|
|
593
|
+
* the generic `SCALIX_PROVIDER_BASE_URL` / `SCALIX_PROVIDER_API_KEY` env vars
|
|
594
|
+
* win, and for `openai` the standard `OPENAI_API_KEY` / `OPENAI_BASE_URL` are
|
|
595
|
+
* also honored. Scalix stays the zero-config default and its path is
|
|
596
|
+
* byte-identical to the pre-BYOK behavior. There is NO default third-party URL
|
|
597
|
+
* — a BYOK selection without a baseUrl resolves with an empty one and the
|
|
598
|
+
* caller surfaces the configuration error.
|
|
459
599
|
*
|
|
460
600
|
* Scalix Cloud credentials resolve separately so BYOK sessions keep the
|
|
461
|
-
* platform tools:
|
|
462
|
-
*
|
|
463
|
-
*
|
|
601
|
+
* platform tools: they come from `SCALIX_CLOUD_API_KEY` → `SCALIX_API_KEY` →
|
|
602
|
+
* the top-level `config.apiKey`, and are simply empty when the user never
|
|
603
|
+
* added one.
|
|
464
604
|
*/
|
|
465
605
|
export function resolveActiveProvider(config, env = process.env) {
|
|
466
606
|
const envProvider = env.SCALIX_PROVIDER?.trim().toLowerCase();
|
|
@@ -469,14 +609,23 @@ export function resolveActiveProvider(config, env = process.env) {
|
|
|
469
609
|
: (config.provider ?? 'scalix');
|
|
470
610
|
const scalixBaseUrl = env.SCALIX_BASE_URL?.trim() || config.baseUrl;
|
|
471
611
|
const scalixApiKey = env.SCALIX_API_KEY?.trim() || config.apiKey;
|
|
472
|
-
|
|
473
|
-
|
|
612
|
+
const cloudBaseUrl = env.SCALIX_CLOUD_BASE_URL?.trim() || scalixBaseUrl;
|
|
613
|
+
const cloudApiKey = env.SCALIX_CLOUD_API_KEY?.trim() || scalixApiKey || '';
|
|
614
|
+
if (provider !== 'scalix') {
|
|
615
|
+
const creds = config.providers?.[provider] ?? {};
|
|
616
|
+
const legacyOpenAI = provider === 'openai';
|
|
474
617
|
return {
|
|
475
618
|
provider,
|
|
476
|
-
baseUrl: env.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
619
|
+
baseUrl: env.SCALIX_PROVIDER_BASE_URL?.trim()
|
|
620
|
+
|| (legacyOpenAI ? env.OPENAI_BASE_URL?.trim() : '')
|
|
621
|
+
|| creds.baseUrl
|
|
622
|
+
|| '',
|
|
623
|
+
apiKey: env.SCALIX_PROVIDER_API_KEY?.trim()
|
|
624
|
+
|| (legacyOpenAI ? env.OPENAI_API_KEY?.trim() : '')
|
|
625
|
+
|| creds.apiKey
|
|
626
|
+
|| '',
|
|
627
|
+
cloudBaseUrl,
|
|
628
|
+
cloudApiKey,
|
|
480
629
|
};
|
|
481
630
|
}
|
|
482
631
|
return {
|
package/dist/slash-commands.js
CHANGED
|
@@ -42,6 +42,7 @@ export const SLASH_COMMANDS = [
|
|
|
42
42
|
{ cmd: '/update', desc: 'Check for and install updates' },
|
|
43
43
|
{ cmd: '/doctor', desc: 'Diagnose installation' },
|
|
44
44
|
{ cmd: '/mcp', desc: 'Add MCP server' },
|
|
45
|
+
{ cmd: '/marketplace', desc: 'Browse & install from the Scalix curated catalog' },
|
|
45
46
|
{ cmd: '/cloud', desc: 'Scalix Cloud status, platform tools & refresh' },
|
|
46
47
|
{ cmd: '/login', desc: 'Link your Scalix account (API key)' },
|
|
47
48
|
{ cmd: '/setup', desc: 'Reconfigure API key, URL & models' },
|
|
@@ -125,6 +126,7 @@ export const HELP_LINES = [
|
|
|
125
126
|
' /skills \u2014 list available skills',
|
|
126
127
|
' /setup \u2014 reconfigure API key, URL & models',
|
|
127
128
|
' /mcp <l> <u> \u2014 add MCP server',
|
|
129
|
+
' /marketplace \u2014 browse/install from the Scalix curated catalog (MCP, skills, rules, subagents)',
|
|
128
130
|
' /plugins \u2014 show loaded plugins',
|
|
129
131
|
' /plugin \u2014 install/enable/disable/uninstall plugins',
|
|
130
132
|
' /trust \u2014 trust workspace (load project plugins/hooks/rules)',
|
package/dist/sub-agent.js
CHANGED
|
@@ -69,6 +69,8 @@ export async function spawnSubAgent(parentConfig, prompt, options = {}) {
|
|
|
69
69
|
apiKey: parentConfig.apiKey,
|
|
70
70
|
baseUrl: parentConfig.baseUrl,
|
|
71
71
|
provider: parentConfig.provider,
|
|
72
|
+
cloudApiKey: parentConfig.cloudApiKey,
|
|
73
|
+
cloudBaseUrl: parentConfig.cloudBaseUrl,
|
|
72
74
|
model,
|
|
73
75
|
instructions: options.instructions || 'You are a focused research assistant. Be concise and factual. Respond in under 500 words.',
|
|
74
76
|
tools,
|
|
@@ -145,6 +147,7 @@ export async function spawnSubAgent(parentConfig, prompt, options = {}) {
|
|
|
145
147
|
merged: mergeResult.merged,
|
|
146
148
|
commits: mergeResult.commitCount,
|
|
147
149
|
conflicts: mergeResult.conflicts?.length ?? 0,
|
|
150
|
+
error: mergeResult.error,
|
|
148
151
|
});
|
|
149
152
|
}
|
|
150
153
|
catch (err) {
|
package/dist/tools-browser.js
CHANGED
|
@@ -467,10 +467,16 @@ export const browserGetTextTool = tool({
|
|
|
467
467
|
},
|
|
468
468
|
});
|
|
469
469
|
const SESSION_DIR = '.scalix/browser-sessions';
|
|
470
|
+
// Session names become file names under .scalix/browser-sessions/ — restrict
|
|
471
|
+
// them to a safe charset so `../` cannot escape the sandbox root.
|
|
472
|
+
const SESSION_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
470
473
|
async function saveSessionState(name) {
|
|
471
474
|
if (!browserContext || !browserPage) {
|
|
472
475
|
throw new Error('No active browser session to save');
|
|
473
476
|
}
|
|
477
|
+
if (!SESSION_NAME_RE.test(name)) {
|
|
478
|
+
throw new Error(`Invalid session name "${name}" — use letters, digits, dot, dash, underscore`);
|
|
479
|
+
}
|
|
474
480
|
const storageState = await browserContext.storageState();
|
|
475
481
|
const url = browserPage.url();
|
|
476
482
|
const sessionDir = path.join(getSandboxRoot(), SESSION_DIR);
|
|
@@ -496,7 +502,7 @@ export const browserSessionSaveTool = tool({
|
|
|
496
502
|
name: 'browser_session_save',
|
|
497
503
|
description: 'Save the current browser session (cookies, localStorage, URL) to disk for later restoration.',
|
|
498
504
|
inputSchema: z.object({
|
|
499
|
-
name: z.string().max(100).describe('Session name (used to restore later)'),
|
|
505
|
+
name: z.string().max(100).regex(SESSION_NAME_RE, 'Session name may only contain letters, digits, dot, dash, underscore (no slashes)').describe('Session name (used to restore later)'),
|
|
500
506
|
}),
|
|
501
507
|
execute: async ({ name }) => {
|
|
502
508
|
try {
|
|
@@ -512,7 +518,7 @@ export const browserSessionRestoreTool = tool({
|
|
|
512
518
|
name: 'browser_session_restore',
|
|
513
519
|
description: 'Restore a previously saved browser session. Recreates the browser context with saved cookies/localStorage and navigates to the saved URL.',
|
|
514
520
|
inputSchema: z.object({
|
|
515
|
-
name: z.string().max(100).describe('Session name to restore'),
|
|
521
|
+
name: z.string().max(100).regex(SESSION_NAME_RE, 'Session name may only contain letters, digits, dot, dash, underscore (no slashes)').describe('Session name to restore'),
|
|
516
522
|
}),
|
|
517
523
|
execute: async ({ name }) => {
|
|
518
524
|
const pw = await getPlaywright();
|
package/dist/tools-extra.js
CHANGED
|
@@ -102,7 +102,11 @@ export const searchFilesTool = tool({
|
|
|
102
102
|
const beforeContext = args['-B'];
|
|
103
103
|
try {
|
|
104
104
|
const cwd = process.cwd();
|
|
105
|
-
|
|
105
|
+
// Contain the search target before ANY backend runs — the rg fast path
|
|
106
|
+
// takes arbitrary paths and would otherwise read outside the sandbox
|
|
107
|
+
// (the built-in fallback below already validates).
|
|
108
|
+
const safeTarget = searchPath ? sandboxPath(searchPath) : '.';
|
|
109
|
+
const target = safeTarget;
|
|
106
110
|
// Try ripgrep first
|
|
107
111
|
const rgArgs = [];
|
|
108
112
|
if (outputMode === 'files_with_matches')
|
|
@@ -136,18 +140,34 @@ export const searchFilesTool = tool({
|
|
|
136
140
|
});
|
|
137
141
|
const lines = stdout.split('\n').filter(Boolean);
|
|
138
142
|
const sliced = lines.slice(offset, headLimit > 0 ? offset + headLimit : undefined);
|
|
143
|
+
// The target is resolved to an absolute sandbox path, so rg prints
|
|
144
|
+
// absolute paths — render them relative to cwd like the fallback does.
|
|
145
|
+
const relPath = (p) => {
|
|
146
|
+
const abs = path.isAbsolute(p) ? p : path.resolve(cwd, p);
|
|
147
|
+
return path.relative(cwd, abs) || '.';
|
|
148
|
+
};
|
|
149
|
+
const splitPrefix = (line, parts) => {
|
|
150
|
+
const segs = line.split(':');
|
|
151
|
+
const file = segs.slice(0, segs.length - parts).join(':');
|
|
152
|
+
const rest = segs.slice(segs.length - parts).join(':');
|
|
153
|
+
return [relPath(file), rest];
|
|
154
|
+
};
|
|
139
155
|
if (outputMode === 'files_with_matches') {
|
|
140
|
-
return { pattern, count: sliced.length, files: sliced };
|
|
156
|
+
return { pattern, count: sliced.length, files: sliced.map(relPath) };
|
|
141
157
|
}
|
|
142
158
|
else if (outputMode === 'count') {
|
|
143
159
|
const counts = sliced.map(line => {
|
|
144
|
-
const [file, cnt] = line
|
|
160
|
+
const [file, cnt] = splitPrefix(line, 1);
|
|
145
161
|
return { file, count: parseInt(cnt, 10) || 0 };
|
|
146
162
|
});
|
|
147
163
|
return { pattern, entries: counts };
|
|
148
164
|
}
|
|
149
165
|
else {
|
|
150
|
-
|
|
166
|
+
const contentLines = sliced.map(line => {
|
|
167
|
+
const [file, rest] = splitPrefix(line, 2);
|
|
168
|
+
return `${file}:${rest}`;
|
|
169
|
+
});
|
|
170
|
+
return { pattern, matchCount: sliced.length, content: contentLines.join('\n') };
|
|
151
171
|
}
|
|
152
172
|
}
|
|
153
173
|
catch (rgErr) {
|
|
@@ -339,6 +359,66 @@ export const findFilesTool = tool({
|
|
|
339
359
|
}
|
|
340
360
|
},
|
|
341
361
|
});
|
|
362
|
+
// Artifacts — the Antigravity-parity surface: reviewable, durable outputs a
|
|
363
|
+
// turn produces (plan, walkthrough, task list, summary) instead of burying
|
|
364
|
+
// them in chat scrollback. Files live under <workspace>/.scalix/artifacts/ so
|
|
365
|
+
// they are plain markdown any editor can open, with frontmatter the UI reads.
|
|
366
|
+
const ARTIFACT_TYPES = ['plan', 'walkthrough', 'tasklist', 'summary'];
|
|
367
|
+
export const writeArtifactTool = tool({
|
|
368
|
+
name: 'write_artifact',
|
|
369
|
+
description: 'Write a reviewable artifact for this task: an implementation plan before multi-step work, a walkthrough of what was done after it, a task list, or a summary. ' +
|
|
370
|
+
'Markdown with a # title. Returns the artifact path.',
|
|
371
|
+
inputSchema: z.object({
|
|
372
|
+
type: z.enum(ARTIFACT_TYPES).describe('plan | walkthrough | tasklist | summary'),
|
|
373
|
+
title: z.string().min(1).max(120).describe('Short human title, e.g. "Auth refactor plan"'),
|
|
374
|
+
content: z.string().min(1).max(100_000).describe('Markdown body (no frontmatter — added automatically).'),
|
|
375
|
+
}),
|
|
376
|
+
execute: async ({ type, title, content }) => {
|
|
377
|
+
try {
|
|
378
|
+
const dir = sandboxPath('.scalix/artifacts');
|
|
379
|
+
await fs.mkdir(dir, { recursive: true });
|
|
380
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'artifact';
|
|
381
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
382
|
+
const rel = `.scalix/artifacts/${stamp}-${type}-${slug}.md`;
|
|
383
|
+
const abs = sandboxPath(rel);
|
|
384
|
+
const frontmatter = `---\ntype: ${type}\ntitle: ${title.replace(/\n/g, ' ')}\ncreatedAt: ${new Date().toISOString()}\n---\n\n`;
|
|
385
|
+
await fs.writeFile(abs, frontmatter + content + (content.endsWith('\n') ? '' : '\n'), 'utf8');
|
|
386
|
+
return { ok: true, path: rel, type, title };
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
return { error: error.message };
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
export const listArtifactsTool = tool({
|
|
394
|
+
name: 'list_artifacts',
|
|
395
|
+
description: 'List artifacts previously written in this workspace (.scalix/artifacts), newest first.',
|
|
396
|
+
inputSchema: z.object({}),
|
|
397
|
+
execute: async () => {
|
|
398
|
+
try {
|
|
399
|
+
const dir = sandboxPath('.scalix/artifacts');
|
|
400
|
+
const names = await fs.readdir(dir);
|
|
401
|
+
const files = names.filter((n) => n.endsWith('.md')).sort().reverse().slice(0, 50);
|
|
402
|
+
const artifacts = await Promise.all(files.map(async (n) => {
|
|
403
|
+
try {
|
|
404
|
+
const head = (await fs.readFile(path.join(dir, n), 'utf8')).slice(0, 512);
|
|
405
|
+
const type = head.match(/^type:\s*(\S+)/m)?.[1] ?? 'unknown';
|
|
406
|
+
const title = head.match(/^title:\s*(.+)$/m)?.[1] ?? n;
|
|
407
|
+
return { path: `.scalix/artifacts/${n}`, type, title };
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return { path: `.scalix/artifacts/${n}`, type: 'unknown', title: n };
|
|
411
|
+
}
|
|
412
|
+
}));
|
|
413
|
+
return { artifacts };
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
if (error.code === 'ENOENT')
|
|
417
|
+
return { artifacts: [] };
|
|
418
|
+
return { error: error.message };
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
});
|
|
342
422
|
export const gitStatusTool = tool({
|
|
343
423
|
name: 'git_status',
|
|
344
424
|
description: 'Get git repository status: current branch, staged/unstaged changes, untracked files.',
|
|
@@ -426,6 +506,8 @@ export const extraTools = [
|
|
|
426
506
|
editFileTool,
|
|
427
507
|
searchFilesTool,
|
|
428
508
|
findFilesTool,
|
|
509
|
+
writeArtifactTool,
|
|
510
|
+
listArtifactsTool,
|
|
429
511
|
gitStatusTool,
|
|
430
512
|
gitDiffTool,
|
|
431
513
|
gitCommitTool,
|