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
package/dist/cli.js
CHANGED
|
@@ -33,6 +33,8 @@ import { checkForUpdate, getUpdateBanner, performUpdate } from './update-checker
|
|
|
33
33
|
import { loadConfig, loadGlobalConfig, saveConfig, isConfigured, resolveActiveProvider, DEFAULT_LIMITS, THINKING_MODES } from './settings.js';
|
|
34
34
|
import { fetchByokModels } from './providers/openai.js';
|
|
35
35
|
import { getModeDescription, PERMISSION_MODES, setSandboxRoot, checkScopedPermission, getScopedPermissionRules } from './sandbox.js';
|
|
36
|
+
import { getCommandSandboxMode, setCommandSandboxMode } from './tools.js';
|
|
37
|
+
import { COMMAND_SANDBOX_MODES } from './command-sandbox.js';
|
|
36
38
|
import { buildProjectIndex, indexToContext } from './project-index.js';
|
|
37
39
|
import { collectGitInfo, gitInfoToContext } from './git-context.js';
|
|
38
40
|
import { loadMemory, autoDetectConventions, saveMemory, memoryToContext, addNote, detectCorrection } from './project-memory.js';
|
|
@@ -52,6 +54,7 @@ import { stringWidth, truncateText } from './text-utils.js';
|
|
|
52
54
|
import { isWorkspaceTrusted, trustWorkspace, hasProjectExecSurface } from './workspace-trust.js';
|
|
53
55
|
import { setLocalOriginApprover } from './browser-access.js';
|
|
54
56
|
import { fetchMe, fetchCloudTools, groupCloudTools, filterCatalogByScopes, cloudKeyRemedy, CONSOLE_KEYS_URL } from './cloud.js';
|
|
57
|
+
import { listMarketplaceListings, getMarketplaceListing, installMarketplaceListing } from './marketplace.js';
|
|
55
58
|
logger.setLevel('silent');
|
|
56
59
|
// Promisified async child_process helpers (keep the TUI responsive vs execSync).
|
|
57
60
|
const execFileAsync = promisify(execFile);
|
|
@@ -342,9 +345,13 @@ function initAgent(config) {
|
|
|
342
345
|
}).catch((err) => {
|
|
343
346
|
logger.warn('Failed to build enhanced instructions', { error: err.message });
|
|
344
347
|
});
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
+
// Custom command templates can carry !`cmd` inline-bash, which executes
|
|
349
|
+
// shell — only load them from a trusted workspace, like plugins/hooks.
|
|
350
|
+
if (isWorkspaceTrusted(process.cwd())) {
|
|
351
|
+
loadCustomCommands(process.cwd()).then((cmds) => {
|
|
352
|
+
customCommands = cmds;
|
|
353
|
+
}).catch((err) => { logger.warn('Failed to load custom commands', { error: err.message }); });
|
|
354
|
+
}
|
|
348
355
|
loadSkills(process.cwd()).then((skills) => {
|
|
349
356
|
loadedSkills = skills;
|
|
350
357
|
}).catch((err) => { logger.warn('Failed to load skills', { error: err.message }); });
|
|
@@ -2769,6 +2776,30 @@ function App({ config }) {
|
|
|
2769
2776
|
}
|
|
2770
2777
|
return true;
|
|
2771
2778
|
}
|
|
2779
|
+
if (cmd === '/sandbox') {
|
|
2780
|
+
const arg = text.split(' ')[1]?.toLowerCase();
|
|
2781
|
+
const current = getCommandSandboxMode();
|
|
2782
|
+
if (arg && COMMAND_SANDBOX_MODES.includes(arg)) {
|
|
2783
|
+
setCommandSandboxMode(arg);
|
|
2784
|
+
loadGlobalConfig().then(cfg => {
|
|
2785
|
+
cfg.commandSandbox = arg;
|
|
2786
|
+
return saveConfig(cfg);
|
|
2787
|
+
}).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
|
|
2788
|
+
const why = arg === 'off'
|
|
2789
|
+
? 'commands run unsandboxed'
|
|
2790
|
+
: arg === 'filesystem'
|
|
2791
|
+
? 'writes confined to the workspace + temp; network allowed'
|
|
2792
|
+
: 'writes confined to the workspace + temp; network denied';
|
|
2793
|
+
add('system', `Command sandbox: ${arg.toUpperCase()} — ${why}. Applies to new run_command calls immediately.`);
|
|
2794
|
+
}
|
|
2795
|
+
else if (!arg) {
|
|
2796
|
+
add('system', `Command sandbox: ${current.toUpperCase()} — usage: /sandbox <off|filesystem|strict>. filesystem = workspace-only writes, strict = also no network.`);
|
|
2797
|
+
}
|
|
2798
|
+
else {
|
|
2799
|
+
add('system', `Unknown sandbox mode "${arg}". Options: off, filesystem, strict.`);
|
|
2800
|
+
}
|
|
2801
|
+
return true;
|
|
2802
|
+
}
|
|
2772
2803
|
if (cmd === '/preset') {
|
|
2773
2804
|
const arg = text.split(' ')[1]?.toLowerCase();
|
|
2774
2805
|
if (arg) {
|
|
@@ -2826,11 +2857,107 @@ function App({ config }) {
|
|
|
2826
2857
|
}
|
|
2827
2858
|
agent.connectMcpServer({ serverLabel: p[1], serverUrl: p[2] }).then((s) => {
|
|
2828
2859
|
add(s.state === 'connected' ? 'system' : 'error', s.state === 'connected'
|
|
2829
|
-
? `MCP connected: ${p[1]}
|
|
2860
|
+
? `MCP connected: ${p[1]} → ${p[2]} (${s.toolCount} tool${s.toolCount === 1 ? '' : 's'})`
|
|
2830
2861
|
: `MCP ${p[1]} failed: ${s.error ?? 'unknown error'}`);
|
|
2831
2862
|
});
|
|
2832
2863
|
return true;
|
|
2833
2864
|
}
|
|
2865
|
+
if (cmd === '/marketplace') {
|
|
2866
|
+
// The Scalix curated catalog (docs/MARKETPLACE.md). Server data is
|
|
2867
|
+
// untrusted input: the client module re-validates every listing before
|
|
2868
|
+
// it reaches disk, and a stdio listing never installs without an
|
|
2869
|
+
// explicit --confirm flag (it spawns a local process).
|
|
2870
|
+
const p = text.split(' ').filter(Boolean);
|
|
2871
|
+
const trustBadge = (t) => t === 'official' ? '★' : t === 'verified' ? '✔' : '·';
|
|
2872
|
+
const kindBadge = (k) => k === 'mcp' ? 'MCP' : k === 'skill' ? 'skill' : k === 'rule' ? 'rule' : 'agent';
|
|
2873
|
+
const renderList = (list) => list.length
|
|
2874
|
+
? list.map(l => `${trustBadge(l.trust)} ${l.id} [${kindBadge(l.kind)} v${l.version || '?'}] — ${l.summary}`).join('\n')
|
|
2875
|
+
: 'No listings matched.';
|
|
2876
|
+
if (p.length === 1) {
|
|
2877
|
+
setIsLoading(true);
|
|
2878
|
+
setStatus('thinking');
|
|
2879
|
+
listMarketplaceListings({ limit: 12 }).then((list) => {
|
|
2880
|
+
add('system', 'Scalix Marketplace — featured catalog:\n' + renderList(list) +
|
|
2881
|
+
'\n\n/marketplace <query> to search · /marketplace info <id> · /marketplace install <id>');
|
|
2882
|
+
}).catch((err) => {
|
|
2883
|
+
add('error', `Marketplace unavailable: ${err.message}`);
|
|
2884
|
+
}).finally(() => { setIsLoading(false); setStatus('ready'); });
|
|
2885
|
+
return true;
|
|
2886
|
+
}
|
|
2887
|
+
if (p[1] === 'install') {
|
|
2888
|
+
const id = p[2];
|
|
2889
|
+
if (!id) {
|
|
2890
|
+
add('system', 'Usage: /marketplace install <id> [--confirm]');
|
|
2891
|
+
return true;
|
|
2892
|
+
}
|
|
2893
|
+
const confirmStdio = p.includes('--confirm');
|
|
2894
|
+
setIsLoading(true);
|
|
2895
|
+
setStatus('thinking');
|
|
2896
|
+
getMarketplaceListing(id).then((detail) => {
|
|
2897
|
+
return installMarketplaceListing(detail, { confirmStdio }).then((result) => {
|
|
2898
|
+
if (result.success) {
|
|
2899
|
+
add('system', result.message);
|
|
2900
|
+
const envNote = detail.payload?.kind === 'mcp' && detail.payload.requiredEnv?.length
|
|
2901
|
+
? `\nSet ${detail.payload.requiredEnv.join(', ')} in your environment, then /mcp to connect.`
|
|
2902
|
+
: '';
|
|
2903
|
+
add('system', envNote.trim());
|
|
2904
|
+
}
|
|
2905
|
+
else {
|
|
2906
|
+
add('error', `Install failed: ${result.error}`);
|
|
2907
|
+
}
|
|
2908
|
+
});
|
|
2909
|
+
}).catch((err) => {
|
|
2910
|
+
add('error', `Install failed: ${err.message}`);
|
|
2911
|
+
}).finally(() => { setIsLoading(false); setStatus('ready'); });
|
|
2912
|
+
return true;
|
|
2913
|
+
}
|
|
2914
|
+
if (p[1] === 'info') {
|
|
2915
|
+
const id = p[2];
|
|
2916
|
+
if (!id) {
|
|
2917
|
+
add('system', 'Usage: /marketplace info <id>');
|
|
2918
|
+
return true;
|
|
2919
|
+
}
|
|
2920
|
+
setIsLoading(true);
|
|
2921
|
+
setStatus('thinking');
|
|
2922
|
+
getMarketplaceListing(id).then((detail) => {
|
|
2923
|
+
const payload = detail.payload;
|
|
2924
|
+
const lines = [
|
|
2925
|
+
`${detail.name} — ${detail.trust} · v${detail.version} by ${detail.author || 'unknown'}`,
|
|
2926
|
+
detail.summary,
|
|
2927
|
+
];
|
|
2928
|
+
if (payload?.kind === 'mcp') {
|
|
2929
|
+
lines.push(payload.transport === 'http'
|
|
2930
|
+
? `Remote MCP over https → ${payload.serverUrl}`
|
|
2931
|
+
: `Local MCP (stdio) → ${payload.command} ${(payload.args ?? []).join(' ')}`.trim());
|
|
2932
|
+
if (payload.requiredEnv?.length)
|
|
2933
|
+
lines.push(`Requires env: ${payload.requiredEnv.join(', ')}`);
|
|
2934
|
+
}
|
|
2935
|
+
else if (payload?.kind === 'skill') {
|
|
2936
|
+
lines.push(`Writes .scalix/skills/${payload.name}/ (${payload.files.length} file(s))`);
|
|
2937
|
+
}
|
|
2938
|
+
else if (payload) {
|
|
2939
|
+
lines.push(`Writes .scalix/${payload.kind === 'rule' ? 'rules' : 'agents'}/${payload.fileName}`);
|
|
2940
|
+
}
|
|
2941
|
+
else {
|
|
2942
|
+
lines.push('No installable payload on this listing.');
|
|
2943
|
+
}
|
|
2944
|
+
add('system', lines.join('\n'));
|
|
2945
|
+
}).catch((err) => {
|
|
2946
|
+
add('error', `Marketplace unavailable: ${err.message}`);
|
|
2947
|
+
}).finally(() => { setIsLoading(false); setStatus('ready'); });
|
|
2948
|
+
return true;
|
|
2949
|
+
}
|
|
2950
|
+
const query = p.slice(1).join(' ');
|
|
2951
|
+
setIsLoading(true);
|
|
2952
|
+
setStatus('thinking');
|
|
2953
|
+
listMarketplaceListings({ q: query, limit: 12 }).then((list) => {
|
|
2954
|
+
add('system', `Scalix Marketplace — "${query}":\n` + renderList(list) +
|
|
2955
|
+
'\n\n/marketplace info <id> · /marketplace install <id>');
|
|
2956
|
+
}).catch((err) => {
|
|
2957
|
+
add('error', `Marketplace unavailable: ${err.message}`);
|
|
2958
|
+
}).finally(() => { setIsLoading(false); setStatus('ready'); });
|
|
2959
|
+
return true;
|
|
2960
|
+
}
|
|
2834
2961
|
if (cmd === '/index') {
|
|
2835
2962
|
setIsLoading(true);
|
|
2836
2963
|
setStatus('thinking');
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS-level command sandbox — the outer wall behind the logical checks in
|
|
3
|
+
* sandbox.ts. When enabled, run_command executes inside a kernel-enforced
|
|
4
|
+
* sandbox instead of a bare shell:
|
|
5
|
+
*
|
|
6
|
+
* filesystem — writes confined to the workspace + system temp; network allowed
|
|
7
|
+
* strict — filesystem confinement PLUS no network (Antigravity's
|
|
8
|
+
* strict-mode equivalent; also the mode to run untrusted builds in)
|
|
9
|
+
*
|
|
10
|
+
* Backends:
|
|
11
|
+
* macOS — sandbox-exec (seatbelt). Deprecated by Apple but still present and
|
|
12
|
+
* used widely; the profile denies file-writes outside the allowlist.
|
|
13
|
+
* Linux — bubblewrap (bwrap). Root filesystem read-only, workspace/tmp bound
|
|
14
|
+
* writable; strict mode unshares the network namespace.
|
|
15
|
+
* Windows — not supported: the tool fails closed with the reason rather than
|
|
16
|
+
* silently running unsandboxed.
|
|
17
|
+
*
|
|
18
|
+
* Mode is opt-in: `commandSandbox` in ~/.scalix-coder/config.json, overridden by
|
|
19
|
+
* SCALIX_COMMAND_SANDBOX (off|filesystem|strict). Default off — this must never
|
|
20
|
+
* change behaviour for an existing install without the user asking for it.
|
|
21
|
+
*
|
|
22
|
+
* Trades: package managers that cache into $HOME (pip, cargo) will fail to
|
|
23
|
+
* write their caches in filesystem mode. That is the point of the wall; the
|
|
24
|
+
* agent sees the EACCES and can tell the user.
|
|
25
|
+
*/
|
|
26
|
+
import * as fs from 'node:fs';
|
|
27
|
+
import * as os from 'node:os';
|
|
28
|
+
import * as path from 'node:path';
|
|
29
|
+
export const COMMAND_SANDBOX_MODES = ['off', 'filesystem', 'strict'];
|
|
30
|
+
const SANDBOX_EXEC = '/usr/bin/sandbox-exec';
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the effective mode. Env wins over config so an operator can force a
|
|
33
|
+
* policy without touching the user's config file; an invalid value anywhere
|
|
34
|
+
* resolves to 'off' (never guesses upward).
|
|
35
|
+
*/
|
|
36
|
+
export function resolveCommandSandboxMode(configValue, envValue) {
|
|
37
|
+
const fromEnv = (envValue ?? '').trim().toLowerCase();
|
|
38
|
+
if (fromEnv) {
|
|
39
|
+
return COMMAND_SANDBOX_MODES.includes(fromEnv) ? fromEnv : 'off';
|
|
40
|
+
}
|
|
41
|
+
const fromConfig = typeof configValue === 'string' ? configValue.trim().toLowerCase() : '';
|
|
42
|
+
if (fromConfig) {
|
|
43
|
+
return COMMAND_SANDBOX_MODES.includes(fromConfig) ? fromConfig : 'off';
|
|
44
|
+
}
|
|
45
|
+
return 'off';
|
|
46
|
+
}
|
|
47
|
+
/** Seatbelt profile: baseline allow, deny all writes, re-allow workspace + temp. */
|
|
48
|
+
export function buildSeatbeltProfile(mode, workspace, tmp) {
|
|
49
|
+
const q = (p) => p.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
50
|
+
const lines = [
|
|
51
|
+
'(version 1)',
|
|
52
|
+
'(allow default)',
|
|
53
|
+
'(deny file-write*)',
|
|
54
|
+
`(allow file-write*`,
|
|
55
|
+
` (subpath "${q(workspace)}")`,
|
|
56
|
+
` (subpath "${q(tmp)}")`,
|
|
57
|
+
// macOS temp roots resolve through /private symlinks; seatbelt matches the
|
|
58
|
+
// literal path, so both spellings are allowlisted.
|
|
59
|
+
` (regex #"^/(private/)?var/folders/")`,
|
|
60
|
+
` (literal "/dev/null")`,
|
|
61
|
+
` (literal "/dev/zero")`,
|
|
62
|
+
` (literal "/dev/dtrace-helper")`,
|
|
63
|
+
`)`,
|
|
64
|
+
];
|
|
65
|
+
if (mode === 'strict') {
|
|
66
|
+
lines.push('(deny network*)');
|
|
67
|
+
}
|
|
68
|
+
return lines.join('\n') + '\n';
|
|
69
|
+
}
|
|
70
|
+
/** Bubblewrap argv prefix: root read-only, workspace + /tmp writable. */
|
|
71
|
+
export function buildBubblewrapArgs(mode, workspace) {
|
|
72
|
+
const args = [
|
|
73
|
+
'--ro-bind', '/', '/',
|
|
74
|
+
'--bind', workspace, workspace,
|
|
75
|
+
'--bind', '/tmp', '/tmp',
|
|
76
|
+
'--dev', '/dev',
|
|
77
|
+
'--proc', '/proc',
|
|
78
|
+
'--tmpfs', '/run',
|
|
79
|
+
];
|
|
80
|
+
if (mode === 'strict') {
|
|
81
|
+
args.push('--unshare-net');
|
|
82
|
+
}
|
|
83
|
+
return args;
|
|
84
|
+
}
|
|
85
|
+
function findOnPath(name) {
|
|
86
|
+
const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
87
|
+
for (const dir of dirs) {
|
|
88
|
+
const candidate = path.join(dir, name);
|
|
89
|
+
try {
|
|
90
|
+
if (fs.statSync(candidate).isFile())
|
|
91
|
+
return candidate;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
/* next */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Build the sandbox wrap for one command execution, or an error explaining why
|
|
101
|
+
* it cannot run (missing backend binary, unsupported platform). Pure aside from
|
|
102
|
+
* filesystem availability probes — safe to call per command.
|
|
103
|
+
*/
|
|
104
|
+
export function buildSandboxWrap(mode, workspace, shell) {
|
|
105
|
+
if (mode === 'off')
|
|
106
|
+
return { ok: false, error: 'sandbox disabled' };
|
|
107
|
+
if (process.platform === 'win32') {
|
|
108
|
+
return { ok: false, error: 'the command sandbox is not supported on Windows — set commandSandbox to "off" in ~/.scalix-coder/config.json or unset SCALIX_COMMAND_SANDBOX' };
|
|
109
|
+
}
|
|
110
|
+
const cwd = path.resolve(workspace);
|
|
111
|
+
if (process.platform === 'darwin') {
|
|
112
|
+
if (!fs.existsSync(SANDBOX_EXEC)) {
|
|
113
|
+
return { ok: false, error: 'sandbox-exec was not found at /usr/bin/sandbox-exec — set commandSandbox to "off" to run without the sandbox' };
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
wrap: {
|
|
118
|
+
execPath: SANDBOX_EXEC,
|
|
119
|
+
// -f (profile file) not -p: the profile embeds absolute paths, and a
|
|
120
|
+
// workspace path containing quotes must never be interpolated into an
|
|
121
|
+
// inline profile string. The caller writes the profile to disk.
|
|
122
|
+
args: ['-f', '@PROFILE@', shell.path, ...shell.args, '@COMMAND@'],
|
|
123
|
+
profile: buildSeatbeltProfile(mode, cwd, os.tmpdir()),
|
|
124
|
+
backend: 'seatbelt',
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// linux and other unix
|
|
129
|
+
const bwrap = findOnPath('bwrap');
|
|
130
|
+
if (!bwrap) {
|
|
131
|
+
return { ok: false, error: 'bubblewrap (bwrap) is not on PATH — install it (e.g. apt install bubblewrap) or set commandSandbox to "off"' };
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
ok: true,
|
|
135
|
+
wrap: {
|
|
136
|
+
execPath: bwrap,
|
|
137
|
+
args: [...buildBubblewrapArgs(mode, cwd), '--', shell.path, ...shell.args, '@COMMAND@'],
|
|
138
|
+
backend: 'bubblewrap',
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** Splice the real command (and, on darwin, the profile file) into a wrap. */
|
|
143
|
+
export function materializeWrap(wrap, command, profilePath) {
|
|
144
|
+
const args = wrap.args.map((a) => (a === '@COMMAND@' ? command : a === '@PROFILE@' ? (profilePath ?? '') : a));
|
|
145
|
+
return { execPath: wrap.execPath, args };
|
|
146
|
+
}
|
package/dist/commands.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { execSync } from 'node:child_process';
|
|
5
5
|
import { logger } from './logger.js';
|
|
6
6
|
import { PROJECT_LOOKUP_DIRS, PROJECT_CONFIG_DIR } from './constants.js';
|
|
7
|
+
import { sanitizeEnvForCommand, sandboxPath } from './sandbox.js';
|
|
7
8
|
const COMMAND_DIRS = PROJECT_LOOKUP_DIRS.map(d => `${d}/commands`);
|
|
8
9
|
function parseFrontmatter(content) {
|
|
9
10
|
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
|
|
@@ -62,12 +63,16 @@ export async function loadCustomCommands(cwd = process.cwd()) {
|
|
|
62
63
|
}
|
|
63
64
|
function expandFileReferences(text, cwd) {
|
|
64
65
|
return text.replace(/@([\w./-]+\.\w+)/g, (_match, filePath) => {
|
|
65
|
-
//
|
|
66
|
-
// project
|
|
67
|
-
|
|
68
|
-
return `@${filePath} (path traversal blocked)`;
|
|
69
|
-
}
|
|
66
|
+
// @file reads go into the prompt, so they must respect the same sandbox as
|
|
67
|
+
// read_file: `..` segments, absolute paths outside the project, and
|
|
68
|
+
// symlinks pointing out are all blocked by sandboxPath().
|
|
70
69
|
const resolved = path.resolve(cwd, filePath);
|
|
70
|
+
try {
|
|
71
|
+
sandboxPath(resolved);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return `@${filePath} (outside project — blocked)`;
|
|
75
|
+
}
|
|
71
76
|
if (!existsSync(resolved))
|
|
72
77
|
return `@${filePath} (file not found)`;
|
|
73
78
|
try {
|
|
@@ -82,7 +87,14 @@ function expandFileReferences(text, cwd) {
|
|
|
82
87
|
function expandInlineBash(text) {
|
|
83
88
|
return text.replace(/!`([^`]+)`/g, (_match, command) => {
|
|
84
89
|
try {
|
|
85
|
-
|
|
90
|
+
// Command templates come from project files. Strip credential-bearing
|
|
91
|
+
// variables from the child environment (same policy as hooks and
|
|
92
|
+
// run_command) so a repo-supplied command cannot read API keys.
|
|
93
|
+
return execSync(command, {
|
|
94
|
+
encoding: 'utf-8',
|
|
95
|
+
timeout: 10_000,
|
|
96
|
+
env: sanitizeEnvForCommand(),
|
|
97
|
+
}).trim();
|
|
86
98
|
}
|
|
87
99
|
catch (err) {
|
|
88
100
|
return `(command failed: ${err.message})`;
|
package/dist/constants.js
CHANGED
|
@@ -76,6 +76,9 @@ export const CORE_INSTRUCTIONS = `You are an expert coding assistant. You write
|
|
|
76
76
|
- Write no comments unless the WHY is non-obvious. Well-named code documents itself.
|
|
77
77
|
- Prefer simple, readable solutions over clever ones.
|
|
78
78
|
|
|
79
|
+
## Artifacts
|
|
80
|
+
- For multi-step work, write an implementation plan with write_artifact (type "plan") before editing, and a walkthrough (type "walkthrough") when the task is done. Use "tasklist" for checklists the user should review, "summary" for research findings. Keep them concise — they are the review surface, not a diary.
|
|
81
|
+
|
|
79
82
|
## Safety
|
|
80
83
|
- Never modify files outside the working directory.
|
|
81
84
|
- Never expose API keys, secrets, or credentials in code.
|
|
@@ -452,6 +455,9 @@ export const PRIVATE_NETWORK_HOSTNAMES = new Set([
|
|
|
452
455
|
'::', '[::]', '0:0:0:0:0:0:0:1', '[0:0:0:0:0:0:0:1]',
|
|
453
456
|
// common cloud metadata endpoints (also covered by 169.254/CIDR, listed for clarity)
|
|
454
457
|
'metadata.google.internal', 'metadata',
|
|
458
|
+
// Alibaba Cloud ECS instance metadata sits outside every RFC-1918 range and
|
|
459
|
+
// outside 169.254/16 — without this entry it is reachable as a "public" host.
|
|
460
|
+
'100.100.100.200',
|
|
455
461
|
]);
|
|
456
462
|
export const PRIVATE_NETWORK_SUFFIXES = ['.local', '.internal', '.localhost'];
|
|
457
463
|
export const PRIVATE_NETWORK_CIDRS = [
|
|
@@ -463,6 +469,8 @@ export const PRIVATE_NETWORK_CIDRS = [
|
|
|
463
469
|
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
464
470
|
/^192\.168\./,
|
|
465
471
|
/^169\.254\./,
|
|
472
|
+
// Alibaba Cloud instance metadata (see hostname note above).
|
|
473
|
+
/^100\.100\.100\.200$/,
|
|
466
474
|
/^\[?fe80:/i,
|
|
467
475
|
/^\[?fc00:/i,
|
|
468
476
|
/^\[?fd[0-9a-f]{2}:/i,
|
package/dist/cost-tracker.js
CHANGED
|
@@ -77,7 +77,7 @@ export class CostTracker {
|
|
|
77
77
|
// only attaches x-scalix-cost-cents to non-streaming responses (the
|
|
78
78
|
// headers are already flushed by the time a stream's usage frame
|
|
79
79
|
// arrives), so a streamed session simply never learns the figure.
|
|
80
|
-
lines.push(provider
|
|
80
|
+
lines.push(provider && provider !== 'scalix'
|
|
81
81
|
? 'Est. cost: not reported by your endpoint — check your provider\'s billing dashboard.'
|
|
82
82
|
: 'Est. cost: not reported on streamed turns — see console.scalix.world → Billing.');
|
|
83
83
|
}
|
package/dist/headless.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import readline from 'node:readline';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import { execSync } from 'node:child_process';
|
|
4
|
-
import { createAgent } from './agent.js';
|
|
4
|
+
import { createAgent, REPORTED_ERROR } from './agent.js';
|
|
5
5
|
import { defaultTools } from './tools.js';
|
|
6
6
|
import { extraTools } from './tools-extra.js';
|
|
7
7
|
import { memoryTools } from './tools-memory.js';
|
|
@@ -241,7 +241,9 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeE
|
|
|
241
241
|
fullResponse = await agent.send(prompt);
|
|
242
242
|
}
|
|
243
243
|
catch (err) {
|
|
244
|
-
|
|
244
|
+
// The agent's 'error' event already printed this — don't print twice.
|
|
245
|
+
if (!err?.[REPORTED_ERROR])
|
|
246
|
+
console.error(`Error: ${err.message}`);
|
|
245
247
|
if (beforeExit)
|
|
246
248
|
await beforeExit();
|
|
247
249
|
process.exit(1);
|
|
@@ -359,7 +361,11 @@ async function runInteractive() {
|
|
|
359
361
|
return Promise.resolve('/exit');
|
|
360
362
|
return new Promise((resolve) => rl.question(question, resolve));
|
|
361
363
|
}
|
|
362
|
-
|
|
364
|
+
// Command templates can carry !`cmd` inline-bash — trust-gate like plugins.
|
|
365
|
+
const customCmds = isWorkspaceTrusted() ? await loadCustomCommands(process.cwd()) : [];
|
|
366
|
+
if (!isWorkspaceTrusted()) {
|
|
367
|
+
process.stderr.write('Project custom commands skipped (untrusted workspace).\n');
|
|
368
|
+
}
|
|
363
369
|
const agent = createAgent({
|
|
364
370
|
apiKey,
|
|
365
371
|
baseUrl,
|
|
@@ -643,7 +649,18 @@ async function runInteractive() {
|
|
|
643
649
|
const cheapModel = pickCheapestModel(agent.getModels());
|
|
644
650
|
console.log(`\nDelegating to sub-agent (${shortModel(cheapModel)})...`);
|
|
645
651
|
try {
|
|
646
|
-
const result = await spawnSubAgent({
|
|
652
|
+
const result = await spawnSubAgent({
|
|
653
|
+
apiKey: apiKey,
|
|
654
|
+
baseUrl,
|
|
655
|
+
// Inherit the parent's provider + cloud credentials so a BYOK
|
|
656
|
+
// session delegates through the same endpoint dialect it runs on;
|
|
657
|
+
// without `provider` the sub-agent defaults to the Scalix client
|
|
658
|
+
// and 404s on every request against a third-party base URL.
|
|
659
|
+
provider: active.provider,
|
|
660
|
+
cloudApiKey: active.cloudApiKey,
|
|
661
|
+
cloudBaseUrl: active.cloudBaseUrl,
|
|
662
|
+
models: agent.getModels(),
|
|
663
|
+
}, prompt, { model: cheapModel, tools: 'read-only', maxSteps: 10 });
|
|
647
664
|
console.log(`\n${result.response}\n`);
|
|
648
665
|
console.log(`\x1b[36mSub-agent: ${result.steps} steps, ~${result.tokenEstimate} tokens\x1b[0m\n`);
|
|
649
666
|
}
|
package/dist/ipc-server.js
CHANGED
|
@@ -28,13 +28,15 @@ import { voiceTools } from './tools-voice.js';
|
|
|
28
28
|
import { browserTools, closeBrowser, setBrowserFrameSink } from './tools-browser.js';
|
|
29
29
|
import { setLocalOriginApprover, listApprovedLocalOrigins, forgetLocalOrigin } from './browser-access.js';
|
|
30
30
|
import { connectMcpServers } from './mcp.js';
|
|
31
|
-
import { loadConfig, resolveActiveProvider, DEFAULT_LIMITS } from './settings.js';
|
|
31
|
+
import { loadConfig, resolveActiveProvider, resolveProviderByKey, summarizeProviders, DEFAULT_LIMITS } from './settings.js';
|
|
32
32
|
import { PERMISSION_MODES } from './sandbox.js';
|
|
33
|
+
import { isProviderId } from './providers/index.js';
|
|
33
34
|
import { discoverPlugins, getPluginTools } from './plugins.js';
|
|
34
35
|
import { isWorkspaceTrusted } from './workspace-trust.js';
|
|
35
36
|
import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_MODEL, fetchModelSpecs, registerUserModels, normalizeModelId, VERSION } from './constants.js';
|
|
36
37
|
import { loadMemory, autoDetectConventions, saveMemory, addNote, detectCorrection } from './project-memory.js';
|
|
37
38
|
import { buildProjectContext, contextToInstructions } from './context-builder.js';
|
|
39
|
+
import { AgentRegistry } from './agent-registry.js';
|
|
38
40
|
// Server→client requests awaiting an answer. Keyed by request id; the value is
|
|
39
41
|
// resolved with whatever `result` object the client sends back. confirmTool was
|
|
40
42
|
// the only user of this for a long time — the browser agent's local-origin
|
|
@@ -169,6 +171,7 @@ export async function runIpcServer() {
|
|
|
169
171
|
agent.on('turn:summary', (i) => ev('turn:summary', i));
|
|
170
172
|
agent.on('thinking:start', () => ev('thinking:start'));
|
|
171
173
|
agent.on('thinking:end', () => ev('thinking:end'));
|
|
174
|
+
agent.on('reasoning:update', (t) => ev('reasoning:update', { text: t }));
|
|
172
175
|
agent.on('usage', (d) => ev('usage', d));
|
|
173
176
|
agent.on('error', (e) => ev('error', { message: e.message }));
|
|
174
177
|
agent.on('context:compress', (i) => ev('context:compress', i));
|
|
@@ -181,13 +184,18 @@ export async function runIpcServer() {
|
|
|
181
184
|
if (sessions.size >= MAX_SESSIONS) {
|
|
182
185
|
throw new Error(`Session limit reached (${MAX_SESSIONS}) — close a chat tab first.`);
|
|
183
186
|
}
|
|
184
|
-
// Per-session provider/creds: overrides let each tab use a different AI
|
|
185
|
-
// (Scalix,
|
|
187
|
+
// Per-session provider/creds: overrides let each tab use a different AI
|
|
188
|
+
// provider (Scalix, any configured BYOK registry entry) with its own API
|
|
189
|
+
// key, base URL, and model selection. An explicit provider key resolves its
|
|
190
|
+
// credentials from the registry, so the front end only has to name it.
|
|
191
|
+
const overrideActive = override?.provider
|
|
192
|
+
? resolveProviderByKey(config, override.provider)
|
|
193
|
+
: null;
|
|
186
194
|
const sessionProvider = override?.provider ?? active.provider;
|
|
187
|
-
const sessionApiKey = override?.apiKey ?? apiKey;
|
|
188
|
-
const sessionBaseUrl = override?.baseUrl ?? baseUrl;
|
|
189
|
-
const sessionCloudApiKey = override?.cloudApiKey ?? active.cloudApiKey;
|
|
190
|
-
const sessionCloudBaseUrl = override?.cloudBaseUrl ?? active.cloudBaseUrl;
|
|
195
|
+
const sessionApiKey = override?.apiKey ?? overrideActive?.apiKey ?? apiKey;
|
|
196
|
+
const sessionBaseUrl = override?.baseUrl ?? overrideActive?.baseUrl ?? baseUrl;
|
|
197
|
+
const sessionCloudApiKey = override?.cloudApiKey ?? overrideActive?.cloudApiKey ?? active.cloudApiKey;
|
|
198
|
+
const sessionCloudBaseUrl = override?.cloudBaseUrl ?? overrideActive?.cloudBaseUrl ?? active.cloudBaseUrl;
|
|
191
199
|
const sessionModels = override?.model ?? selectedModels;
|
|
192
200
|
const sessionPermissionMode = override?.permissionMode ?? permissionMode;
|
|
193
201
|
const agent = createAgent({
|
|
@@ -236,7 +244,7 @@ export async function runIpcServer() {
|
|
|
236
244
|
// Signal ready with capability info. `version` lets the extension detect
|
|
237
245
|
// engine/extension drift (a globally-installed CLI can lag the bundled one).
|
|
238
246
|
// `multiSession` tells the extension it may open parallel chat sessions.
|
|
239
|
-
write({ type: 'event', event: 'ready', data: { version: VERSION, tools: allTools.length, models: selectedModels, permissionMode, multiSession: true } });
|
|
247
|
+
write({ type: 'event', event: 'ready', data: { version: VERSION, tools: allTools.length, models: selectedModels, permissionMode, multiSession: true, provider: active.provider, providers: summarizeProviders(config) } });
|
|
240
248
|
if (defaultSession.agent.getSkippedHookCount() > 0) {
|
|
241
249
|
process.stderr.write(`IPC: project hooks skipped (untrusted workspace): ${defaultSession.agent.getSkippedHookCount()}\n`);
|
|
242
250
|
}
|
|
@@ -257,6 +265,13 @@ export async function runIpcServer() {
|
|
|
257
265
|
}
|
|
258
266
|
})
|
|
259
267
|
.catch(err => { process.stderr.write(`IPC: MCP startup failed: ${err?.message || err}\n`); });
|
|
268
|
+
// Sub-agent lifecycle → the IDE's Agents tab. The registry is a process-wide
|
|
269
|
+
// singleton (the session agents and every spawned sub-agent register in it),
|
|
270
|
+
// so hook it once here and broadcast each spawn/completion as an event.
|
|
271
|
+
const registry = AgentRegistry.getInstance();
|
|
272
|
+
const subagentEvent = (entry) => write({ type: 'event', event: 'subagent', data: entry });
|
|
273
|
+
registry.on('agent:spawned', subagentEvent);
|
|
274
|
+
registry.on('agent:completed', subagentEvent);
|
|
260
275
|
// Process JSONL from stdin — session-routed requests
|
|
261
276
|
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
262
277
|
rl.on('line', async (line) => {
|
|
@@ -295,7 +310,7 @@ export async function runIpcServer() {
|
|
|
295
310
|
break;
|
|
296
311
|
}
|
|
297
312
|
const override = {};
|
|
298
|
-
if (params?.provider
|
|
313
|
+
if (isProviderId(params?.provider))
|
|
299
314
|
override.provider = params.provider;
|
|
300
315
|
if (params?.apiKey && typeof params.apiKey === 'string')
|
|
301
316
|
override.apiKey = params.apiKey;
|
|
@@ -434,7 +449,11 @@ export async function runIpcServer() {
|
|
|
434
449
|
}
|
|
435
450
|
case 'getModels': {
|
|
436
451
|
const session = resolveSession(params);
|
|
437
|
-
write({ type: 'response', id, result: { models: session.agent.getModels() } });
|
|
452
|
+
write({ type: 'response', id, result: { models: session.agent.getModels(), provider: session.agent.getProvider() } });
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
case 'getProviders': {
|
|
456
|
+
write({ type: 'response', id, result: { providers: summarizeProviders(config), active: active.provider } });
|
|
438
457
|
break;
|
|
439
458
|
}
|
|
440
459
|
case 'registerModels': {
|
|
@@ -495,11 +514,23 @@ export async function runIpcServer() {
|
|
|
495
514
|
}
|
|
496
515
|
case 'setPermissionMode': {
|
|
497
516
|
const session = resolveSession(params);
|
|
498
|
-
|
|
517
|
+
// Same whitelist createSession applies — an unvalidated mode string
|
|
518
|
+
// would degrade every tool to 'ask' (fail-safe) but leave the
|
|
519
|
+
// session reporting a mode that does not exist.
|
|
520
|
+
if (params?.mode && PERMISSION_MODES.includes(params.mode)) {
|
|
499
521
|
session.agent.setPermissionMode(params.mode);
|
|
522
|
+
}
|
|
500
523
|
write({ type: 'response', id, result: { mode: session.agent.getPermissionMode() } });
|
|
501
524
|
break;
|
|
502
525
|
}
|
|
526
|
+
case 'listAgents': {
|
|
527
|
+
// The IDE's Agents tab polls this: every registered agent (session
|
|
528
|
+
// roots plus sub-agents spawned through the `agent` tool) with its
|
|
529
|
+
// live status, model, token and step counts.
|
|
530
|
+
const all = AgentRegistry.getInstance().listAll();
|
|
531
|
+
write({ type: 'response', id, result: { agents: all, usage: AgentRegistry.getInstance().getUsageSummary() } });
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
503
534
|
default:
|
|
504
535
|
write({ type: 'response', id, error: { message: `Unknown method: ${method}` } });
|
|
505
536
|
}
|