scalix 1.2.0 → 1.3.2
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/agent.js +57 -30
- package/dist/api-client.js +44 -9
- package/dist/auto-verify.js +3 -1
- package/dist/cli.js +392 -167
- package/dist/commands.js +19 -2
- package/dist/constants.js +84 -3
- package/dist/context-retriever.js +1 -1
- package/dist/cost-tracker.js +21 -16
- package/dist/git-context.js +8 -5
- package/dist/grounding-checker.js +8 -1
- package/dist/headless.js +86 -43
- package/dist/hooks.js +55 -5
- package/dist/injection-detector.js +18 -1
- package/dist/ipc-server.js +7 -1
- package/dist/keychain.js +117 -4
- package/dist/markdown.js +4 -1
- package/dist/model-router.js +11 -0
- package/dist/sandbox.js +73 -9
- package/dist/scoped-rules.js +10 -1
- package/dist/sessions.js +6 -6
- package/dist/settings.js +53 -14
- package/dist/sub-agent.js +59 -2
- package/dist/token-counter.js +10 -1
- package/dist/tool-helper.js +12 -4
- package/dist/tools-browser.js +65 -6
- package/dist/tools-extra.js +42 -6
- package/dist/tools-web.js +76 -22
- package/dist/tools.js +27 -2
- package/dist/workspace-trust.js +90 -0
- package/package.json +1 -1
package/dist/commands.js
CHANGED
|
@@ -62,6 +62,11 @@ export async function loadCustomCommands(cwd = process.cwd()) {
|
|
|
62
62
|
}
|
|
63
63
|
function expandFileReferences(text, cwd) {
|
|
64
64
|
return text.replace(/@([\w./-]+\.\w+)/g, (_match, filePath) => {
|
|
65
|
+
// Reject path traversal so a command file can't pull in files outside the
|
|
66
|
+
// project via `@../../etc/passwd`.
|
|
67
|
+
if (filePath.split(/[\\/]/).includes('..')) {
|
|
68
|
+
return `@${filePath} (path traversal blocked)`;
|
|
69
|
+
}
|
|
65
70
|
const resolved = path.resolve(cwd, filePath);
|
|
66
71
|
if (!existsSync(resolved))
|
|
67
72
|
return `@${filePath} (file not found)`;
|
|
@@ -84,9 +89,18 @@ function expandInlineBash(text) {
|
|
|
84
89
|
}
|
|
85
90
|
});
|
|
86
91
|
}
|
|
92
|
+
// Apply the same sanitization used for $ARGS to a single argument so positional
|
|
93
|
+
// args ($1..$9) can't smuggle shell metacharacters into a later `!`cmd`` inline
|
|
94
|
+
// bash expansion.
|
|
95
|
+
function sanitizeArg(arg) {
|
|
96
|
+
return arg
|
|
97
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '')
|
|
98
|
+
.replace(/\$/g, '\\$')
|
|
99
|
+
.replace(/`/g, '\\`');
|
|
100
|
+
}
|
|
87
101
|
function expandPositionalArgs(text, args) {
|
|
88
102
|
const parts = args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
|
|
89
|
-
const cleaned = parts.map(p => p.replace(/^["']|["']$/g, ''));
|
|
103
|
+
const cleaned = parts.map(p => sanitizeArg(p.replace(/^["']|["']$/g, '')));
|
|
90
104
|
let result = text;
|
|
91
105
|
for (let i = 0; i < 9; i++) {
|
|
92
106
|
const placeholder = `$${i + 1}`;
|
|
@@ -100,12 +114,15 @@ export function expandCommand(command, args) {
|
|
|
100
114
|
.replace(/\$/g, '\\$')
|
|
101
115
|
.replace(/`/g, '\\`');
|
|
102
116
|
let prompt = command.template;
|
|
117
|
+
// Run inline bash (!`cmd`) FIRST, against the raw template only, so that no
|
|
118
|
+
// user-supplied argument can be substituted into a command line before it is
|
|
119
|
+
// executed. Any $ / backtick coming from args afterward stays inert text.
|
|
120
|
+
prompt = expandInlineBash(prompt);
|
|
103
121
|
prompt = expandPositionalArgs(prompt, args);
|
|
104
122
|
prompt = prompt.replace(/\$ARGS/g, safeArgs);
|
|
105
123
|
prompt = prompt.replace(/\$CWD/g, process.cwd());
|
|
106
124
|
prompt = prompt.replace(/\$DATE/g, new Date().toISOString().split('T')[0]);
|
|
107
125
|
prompt = expandFileReferences(prompt, process.cwd());
|
|
108
|
-
prompt = expandInlineBash(prompt);
|
|
109
126
|
return prompt;
|
|
110
127
|
}
|
|
111
128
|
export function commandsSummary(commands) {
|
package/dist/constants.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const VERSION = '1.
|
|
1
|
+
export const VERSION = '1.3.0';
|
|
2
2
|
export const PRODUCT_NAME = 'Scalix-Coder';
|
|
3
3
|
export const USER_AGENT = `${PRODUCT_NAME}/${VERSION}`;
|
|
4
4
|
export const DEFAULT_BASE_URL = process.env.SCALIX_BASE_URL || 'https://api.scalix.world/v1';
|
|
@@ -38,7 +38,7 @@ export const DEFAULT_INSTRUCTIONS = `You are an expert coding assistant. You wri
|
|
|
38
38
|
|
|
39
39
|
## Scalix Platform
|
|
40
40
|
When helping users build AI applications, agents, or LLM-powered features:
|
|
41
|
-
- Use the Scalix SDK (\`@scalix-world/sdk\` for TypeScript, \`scalix\` for Python) — not raw OpenAI/Anthropic/Google SDKs.
|
|
41
|
+
- Use the Scalix SDK (\`@scalix-world/sdk\` for TypeScript, \`scalix-sdk\` for Python) — not raw OpenAI/Anthropic/Google SDKs.
|
|
42
42
|
- Default to Scalix API (api.scalix.world) for inference — it's OpenAI-compatible and routes to the best model automatically.
|
|
43
43
|
- For deployment, suggest \`scalix hosting\` before Vercel, Railway, or Fly.
|
|
44
44
|
- For databases, suggest ScalixDB before Supabase, Firebase, or PlanetScale.
|
|
@@ -272,8 +272,11 @@ export const TOKEN_ESTIMATION = {
|
|
|
272
272
|
export const PRIVATE_NETWORK_HOSTNAMES = new Set([
|
|
273
273
|
'localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0',
|
|
274
274
|
'::ffff:127.0.0.1', '[::ffff:127.0.0.1]',
|
|
275
|
+
'::', '[::]', '0:0:0:0:0:0:0:1', '[0:0:0:0:0:0:0:1]',
|
|
276
|
+
// common cloud metadata endpoints (also covered by 169.254/CIDR, listed for clarity)
|
|
277
|
+
'metadata.google.internal', 'metadata',
|
|
275
278
|
]);
|
|
276
|
-
export const PRIVATE_NETWORK_SUFFIXES = ['.local', '.internal'];
|
|
279
|
+
export const PRIVATE_NETWORK_SUFFIXES = ['.local', '.internal', '.localhost'];
|
|
277
280
|
export const PRIVATE_NETWORK_CIDRS = [
|
|
278
281
|
/^10\./,
|
|
279
282
|
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
@@ -292,6 +295,78 @@ export function setNetworkSecurityOverrides(overrides) {
|
|
|
292
295
|
allowedHosts: overrides.allowedHosts ? new Set(overrides.allowedHosts.map(h => h.toLowerCase())) : undefined,
|
|
293
296
|
};
|
|
294
297
|
}
|
|
298
|
+
/**
|
|
299
|
+
* Canonicalize a host string to a comparable IPv4 dotted-quad when it is a
|
|
300
|
+
* numeric address written in a non-standard form (decimal, hex, octal, or
|
|
301
|
+
* with fewer than 4 parts). Returns null when the input is not a bare IPv4-ish
|
|
302
|
+
* number. This closes SSRF bypasses like http://2130706433/ (=127.0.0.1),
|
|
303
|
+
* http://0x7f.1/ or http://127.1/.
|
|
304
|
+
*/
|
|
305
|
+
export function canonicalizeIpv4(host) {
|
|
306
|
+
const h = host.trim().replace(/^\[|\]$/g, '');
|
|
307
|
+
if (!/^[0-9a-fx.]+$/i.test(h))
|
|
308
|
+
return null;
|
|
309
|
+
const parts = h.split('.');
|
|
310
|
+
if (parts.length === 0 || parts.length > 4)
|
|
311
|
+
return null;
|
|
312
|
+
const nums = [];
|
|
313
|
+
for (const p of parts) {
|
|
314
|
+
if (p === '')
|
|
315
|
+
return null;
|
|
316
|
+
let n;
|
|
317
|
+
if (/^0x[0-9a-f]+$/i.test(p))
|
|
318
|
+
n = parseInt(p, 16);
|
|
319
|
+
else if (/^0[0-7]+$/.test(p))
|
|
320
|
+
n = parseInt(p, 8);
|
|
321
|
+
else if (/^[0-9]+$/.test(p))
|
|
322
|
+
n = parseInt(p, 10);
|
|
323
|
+
else
|
|
324
|
+
return null;
|
|
325
|
+
if (!Number.isFinite(n) || n < 0)
|
|
326
|
+
return null;
|
|
327
|
+
nums.push(n);
|
|
328
|
+
}
|
|
329
|
+
// Fold the trailing part as a "rest" value per inet_aton semantics.
|
|
330
|
+
let value;
|
|
331
|
+
if (nums.length === 1) {
|
|
332
|
+
value = nums[0];
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
const last = nums[nums.length - 1];
|
|
336
|
+
const head = nums.slice(0, -1);
|
|
337
|
+
if (head.some(n => n > 255))
|
|
338
|
+
return null;
|
|
339
|
+
const restMax = Math.pow(256, 4 - head.length);
|
|
340
|
+
if (last >= restMax)
|
|
341
|
+
return null;
|
|
342
|
+
value = last;
|
|
343
|
+
for (let i = 0; i < head.length; i++) {
|
|
344
|
+
value += head[i] * Math.pow(256, 3 - i);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (value < 0 || value > 0xffffffff)
|
|
348
|
+
return null;
|
|
349
|
+
return [
|
|
350
|
+
(value >>> 24) & 0xff,
|
|
351
|
+
(value >>> 16) & 0xff,
|
|
352
|
+
(value >>> 8) & 0xff,
|
|
353
|
+
value & 0xff,
|
|
354
|
+
].join('.');
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Check whether a concrete IP address (already resolved, or a literal) points
|
|
358
|
+
* at a private/loopback/link-local/metadata range. Use this after DNS
|
|
359
|
+
* resolution to defeat DNS-rebinding and numeric-encoding SSRF.
|
|
360
|
+
*/
|
|
361
|
+
export function isPrivateIp(ip) {
|
|
362
|
+
const canon = canonicalizeIpv4(ip);
|
|
363
|
+
const candidate = (canon ?? ip).toLowerCase();
|
|
364
|
+
if (PRIVATE_NETWORK_HOSTNAMES.has(candidate))
|
|
365
|
+
return true;
|
|
366
|
+
if (PRIVATE_NETWORK_CIDRS.some(re => re.test(candidate)))
|
|
367
|
+
return true;
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
295
370
|
export function isPrivateHost(hostname) {
|
|
296
371
|
const lower = hostname.toLowerCase();
|
|
297
372
|
if (_networkOverrides.allowAll)
|
|
@@ -300,6 +375,12 @@ export function isPrivateHost(hostname) {
|
|
|
300
375
|
return false;
|
|
301
376
|
if (_networkOverrides.blockedHosts?.has(lower))
|
|
302
377
|
return true;
|
|
378
|
+
// Normalize numeric IPv4 encodings (decimal/hex/octal/short forms) so that
|
|
379
|
+
// e.g. 2130706433, 0x7f000001 and 127.1 are all recognized as loopback.
|
|
380
|
+
const canonical = canonicalizeIpv4(lower);
|
|
381
|
+
if (canonical && (PRIVATE_NETWORK_HOSTNAMES.has(canonical) || PRIVATE_NETWORK_CIDRS.some(re => re.test(canonical)))) {
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
303
384
|
if (PRIVATE_NETWORK_HOSTNAMES.has(lower))
|
|
304
385
|
return true;
|
|
305
386
|
if (PRIVATE_NETWORK_SUFFIXES.some(s => lower.endsWith(s)))
|
|
@@ -70,7 +70,7 @@ function scoreFiles(analysis, index) {
|
|
|
70
70
|
deps.add(imp.to);
|
|
71
71
|
importGraph.set(imp.from, deps);
|
|
72
72
|
}
|
|
73
|
-
const recentFiles = new Set(index.files
|
|
73
|
+
const recentFiles = new Set([...index.files]
|
|
74
74
|
.sort((a, b) => b.modifiedAt - a.modifiedAt)
|
|
75
75
|
.slice(0, 20)
|
|
76
76
|
.map((f) => f.relativePath));
|
package/dist/cost-tracker.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export class CostTracker {
|
|
2
2
|
entries = [];
|
|
3
3
|
sessionStart = Date.now();
|
|
4
|
+
restoredRequestCount = 0;
|
|
4
5
|
record(model, usage) {
|
|
5
6
|
this.entries.push({
|
|
6
7
|
model,
|
|
@@ -24,7 +25,10 @@ export class CostTracker {
|
|
|
24
25
|
return this.entries.reduce((s, e) => s + e.completionTokens, 0);
|
|
25
26
|
}
|
|
26
27
|
get requestCount() {
|
|
27
|
-
|
|
28
|
+
// The single restored aggregate entry stands in for the previous session's
|
|
29
|
+
// request count, tracked separately so we don't over-count live requests.
|
|
30
|
+
const liveEntries = this.entries.filter((e) => e.model !== '(previous session)').length;
|
|
31
|
+
return liveEntries + this.restoredRequestCount;
|
|
28
32
|
}
|
|
29
33
|
get sessionDuration() {
|
|
30
34
|
const s = Math.floor((Date.now() - this.sessionStart) / 1000);
|
|
@@ -57,25 +61,26 @@ export class CostTracker {
|
|
|
57
61
|
}
|
|
58
62
|
reset() {
|
|
59
63
|
this.entries = [];
|
|
64
|
+
this.restoredRequestCount = 0;
|
|
60
65
|
this.sessionStart = Date.now();
|
|
61
66
|
}
|
|
62
67
|
restore(state) {
|
|
63
68
|
this.entries = [];
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
69
|
+
// Store a single aggregate entry rather than fabricating `requestCount`
|
|
70
|
+
// synthetic entries — a corrupted/huge count would otherwise allocate
|
|
71
|
+
// unboundedly. The original request count is preserved separately.
|
|
72
|
+
this.restoredRequestCount = Number.isFinite(state.requestCount) && state.requestCount > 0
|
|
73
|
+
? Math.floor(state.requestCount)
|
|
74
|
+
: 0;
|
|
75
|
+
if (this.restoredRequestCount > 0 && state.totalTokens >= 0) {
|
|
76
|
+
this.entries.push({
|
|
77
|
+
model: '(previous session)',
|
|
78
|
+
promptTokens: 0,
|
|
79
|
+
completionTokens: 0,
|
|
80
|
+
totalTokens: state.totalTokens,
|
|
81
|
+
cost: state.totalCost,
|
|
82
|
+
timestamp: Date.now(),
|
|
83
|
+
});
|
|
79
84
|
}
|
|
80
85
|
}
|
|
81
86
|
getState() {
|
package/dist/git-context.js
CHANGED
|
@@ -18,13 +18,13 @@ async function getBranch(cwd) {
|
|
|
18
18
|
return (await run('git branch --show-current', cwd)) || (await run('git rev-parse --short HEAD', cwd)) || 'unknown';
|
|
19
19
|
}
|
|
20
20
|
async function getRemoteBranch(cwd) {
|
|
21
|
-
return run('git rev-parse --abbrev-ref @{upstream}
|
|
21
|
+
return run('git rev-parse --abbrev-ref @{upstream}', cwd);
|
|
22
22
|
}
|
|
23
23
|
async function getRemoteUrl(cwd) {
|
|
24
|
-
return run('git remote get-url origin
|
|
24
|
+
return run('git remote get-url origin', cwd);
|
|
25
25
|
}
|
|
26
26
|
async function getAheadBehind(cwd) {
|
|
27
|
-
const result = await run('git rev-list --left-right --count @{upstream}...HEAD
|
|
27
|
+
const result = await run('git rev-list --left-right --count @{upstream}...HEAD', cwd);
|
|
28
28
|
if (!result)
|
|
29
29
|
return { ahead: 0, behind: 0 };
|
|
30
30
|
const parts = result.split('\t');
|
|
@@ -33,7 +33,7 @@ async function getAheadBehind(cwd) {
|
|
|
33
33
|
return { ahead: Number(parts[1]) || 0, behind: Number(parts[0]) || 0 };
|
|
34
34
|
}
|
|
35
35
|
async function getStashCount(cwd) {
|
|
36
|
-
const result = await run('git stash list
|
|
36
|
+
const result = await run('git stash list', cwd);
|
|
37
37
|
return result ? result.split('\n').filter(Boolean).length : 0;
|
|
38
38
|
}
|
|
39
39
|
async function getStatus(cwd) {
|
|
@@ -74,7 +74,10 @@ async function getStatus(cwd) {
|
|
|
74
74
|
async function getRecentCommits(cwd, count = GIT_CONTEXT_DEFAULTS.recentCommitsCount) {
|
|
75
75
|
const SEP = '<%>';
|
|
76
76
|
const format = `%H${SEP}%h${SEP}%s${SEP}%an${SEP}%ar`;
|
|
77
|
-
|
|
77
|
+
// No shell redirects here: `2>/dev/null` breaks under cmd.exe on Windows
|
|
78
|
+
// (redirects to a literal \dev\null path and fails the whole command).
|
|
79
|
+
// run() already discards stderr and swallows failures.
|
|
80
|
+
const result = await run(`git log --pretty=format:"${format}" --shortstat -n ${count}`, cwd);
|
|
78
81
|
if (!result)
|
|
79
82
|
return [];
|
|
80
83
|
const commits = [];
|
|
@@ -20,7 +20,14 @@ function surroundingContext(text, pos, radius = 60) {
|
|
|
20
20
|
const end = Math.min(text.length, pos + radius);
|
|
21
21
|
return text.slice(start, end).replace(/\n/g, ' ').trim();
|
|
22
22
|
}
|
|
23
|
-
|
|
23
|
+
// Blank out fenced code blocks (```...```) with equal-length whitespace so the
|
|
24
|
+
// model's own newly-authored symbols aren't flagged as ungrounded, while
|
|
25
|
+
// keeping character offsets stable for surroundingContext().
|
|
26
|
+
function maskFencedCodeBlocks(text) {
|
|
27
|
+
return text.replace(/```[\s\S]*?(?:```|$)/g, (block) => block.replace(/[^\n]/g, ' '));
|
|
28
|
+
}
|
|
29
|
+
export function extractCodeClaims(rawText) {
|
|
30
|
+
const text = maskFencedCodeBlocks(rawText);
|
|
24
31
|
const seen = new Set();
|
|
25
32
|
const claims = [];
|
|
26
33
|
function add(type, value, position) {
|
package/dist/headless.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import readline from 'node:readline';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
|
-
import path from 'node:path';
|
|
4
3
|
import { execSync } from 'node:child_process';
|
|
5
4
|
import { createAgent } from './agent.js';
|
|
6
5
|
import { defaultTools } from './tools.js';
|
|
@@ -23,6 +22,7 @@ import { loadMemory, autoDetectConventions, saveMemory, memoryToContext, addNote
|
|
|
23
22
|
import { buildProjectIndex } from './project-index.js';
|
|
24
23
|
import { buildProjectContext, contextToInstructions } from './context-builder.js';
|
|
25
24
|
import { discoverPlugins, getPluginTools, pluginsSummary } from './plugins.js';
|
|
25
|
+
import { isWorkspaceTrusted } from './workspace-trust.js';
|
|
26
26
|
import { loadCustomCommands, expandCommand, commandsSummary } from './commands.js';
|
|
27
27
|
import { hooksSummary } from './hooks.js';
|
|
28
28
|
import { spawnSubAgent, pickCheapestModel } from './sub-agent.js';
|
|
@@ -131,7 +131,7 @@ async function buildEnhancedInstructions() {
|
|
|
131
131
|
const ctx = await buildProjectContext(cwd);
|
|
132
132
|
return contextToInstructions(base, ctx);
|
|
133
133
|
}
|
|
134
|
-
async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
|
|
134
|
+
async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeExit) {
|
|
135
135
|
const apiKey = process.env[ENV_API_KEY];
|
|
136
136
|
if (!apiKey) {
|
|
137
137
|
console.error('Error: SCALIX_API_KEY is not set.\nSet it via: export SCALIX_API_KEY=sk-...\nOr run the interactive setup: scalix');
|
|
@@ -159,7 +159,12 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
|
|
|
159
159
|
process.exit(1);
|
|
160
160
|
}
|
|
161
161
|
const mcpTools = loadMcpServices();
|
|
162
|
-
|
|
162
|
+
// Only discover/load project-local plugins in a trusted workspace — plugins
|
|
163
|
+
// dynamically import and execute code from the project directory.
|
|
164
|
+
const plugins = isWorkspaceTrusted() ? await discoverPlugins(process.cwd()) : [];
|
|
165
|
+
if (!isWorkspaceTrusted()) {
|
|
166
|
+
process.stderr.write('Project plugins skipped (untrusted workspace).\n');
|
|
167
|
+
}
|
|
163
168
|
const pluginTools = getPluginTools(plugins);
|
|
164
169
|
const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools, ...pluginTools];
|
|
165
170
|
const instructions = await buildEnhancedInstructions();
|
|
@@ -202,16 +207,22 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
|
|
|
202
207
|
contextRetrieval: true,
|
|
203
208
|
});
|
|
204
209
|
let fullResponse = '';
|
|
205
|
-
agent.on('stream:end', (text) => { fullResponse = text; });
|
|
206
210
|
agent.on('tool:call', (name) => process.stderr.write(`\x1b[33m▶ ${name}\x1b[0m\n`));
|
|
207
211
|
agent.on('verify:pass', (r) => process.stderr.write(`\x1b[32m✓ ${r.checker} passed\x1b[0m\n`));
|
|
208
212
|
agent.on('verify:fail', (r) => process.stderr.write(`\x1b[31m✗ ${r.checker} failed: ${r.output.split('\n')[0]}\x1b[0m\n`));
|
|
209
213
|
agent.on('error', (err) => process.stderr.write(`\x1b[31mError: ${err.message}\x1b[0m\n`));
|
|
214
|
+
agent.on('usage', (data) => {
|
|
215
|
+
costTracker.record(data.model || selectedModel[0], data);
|
|
216
|
+
});
|
|
210
217
|
try {
|
|
211
|
-
|
|
218
|
+
// Use the return value (applies JSON extraction for --format json/json-schema)
|
|
219
|
+
// rather than the raw stream text.
|
|
220
|
+
fullResponse = await agent.send(prompt);
|
|
212
221
|
}
|
|
213
222
|
catch (err) {
|
|
214
223
|
console.error(`Error: ${err.message}`);
|
|
224
|
+
if (beforeExit)
|
|
225
|
+
await beforeExit();
|
|
215
226
|
process.exit(1);
|
|
216
227
|
}
|
|
217
228
|
let formatted;
|
|
@@ -245,9 +256,11 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
|
|
|
245
256
|
process.stdout.write(formatted + '\n');
|
|
246
257
|
}
|
|
247
258
|
await closeBrowser();
|
|
259
|
+
if (beforeExit)
|
|
260
|
+
await beforeExit();
|
|
248
261
|
process.exit(0);
|
|
249
262
|
}
|
|
250
|
-
async function runPipeMode() {
|
|
263
|
+
async function runPipeMode(beforeExit) {
|
|
251
264
|
const MAX_PIPE_BYTES = 50 * 1024 * 1024;
|
|
252
265
|
const chunks = [];
|
|
253
266
|
let totalBytes = 0;
|
|
@@ -257,6 +270,8 @@ async function runPipeMode() {
|
|
|
257
270
|
totalBytes += Buffer.byteLength(str);
|
|
258
271
|
if (totalBytes > MAX_PIPE_BYTES) {
|
|
259
272
|
console.error(`Error: Pipe input exceeds ${MAX_PIPE_BYTES / 1024 / 1024}MB limit.`);
|
|
273
|
+
if (beforeExit)
|
|
274
|
+
await beforeExit();
|
|
260
275
|
process.exit(1);
|
|
261
276
|
}
|
|
262
277
|
chunks.push(str);
|
|
@@ -264,14 +279,18 @@ async function runPipeMode() {
|
|
|
264
279
|
}
|
|
265
280
|
catch (err) {
|
|
266
281
|
console.error(`Error reading pipe input: ${err.message}`);
|
|
282
|
+
if (beforeExit)
|
|
283
|
+
await beforeExit();
|
|
267
284
|
process.exit(1);
|
|
268
285
|
}
|
|
269
286
|
const prompt = chunks.join('').trim();
|
|
270
287
|
if (!prompt) {
|
|
271
288
|
console.error('Error: No input received from stdin.');
|
|
289
|
+
if (beforeExit)
|
|
290
|
+
await beforeExit();
|
|
272
291
|
process.exit(1);
|
|
273
292
|
}
|
|
274
|
-
await runSinglePrompt(prompt);
|
|
293
|
+
await runSinglePrompt(prompt, undefined, undefined, undefined, beforeExit);
|
|
275
294
|
}
|
|
276
295
|
async function runInteractive() {
|
|
277
296
|
const apiKey = process.env[ENV_API_KEY];
|
|
@@ -292,7 +311,12 @@ async function runInteractive() {
|
|
|
292
311
|
throw new Error('No models configured. Set SCALIX_MODEL env var or run interactive setup.');
|
|
293
312
|
}
|
|
294
313
|
const mcpTools = loadMcpServices();
|
|
295
|
-
|
|
314
|
+
// Only discover/load project-local plugins in a trusted workspace — plugins
|
|
315
|
+
// dynamically import and execute code from the project directory.
|
|
316
|
+
const plugins = isWorkspaceTrusted() ? await discoverPlugins(process.cwd()) : [];
|
|
317
|
+
if (!isWorkspaceTrusted()) {
|
|
318
|
+
process.stderr.write('Project plugins skipped (untrusted workspace).\n');
|
|
319
|
+
}
|
|
296
320
|
const pluginTools = getPluginTools(plugins);
|
|
297
321
|
const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools, ...pluginTools];
|
|
298
322
|
const instructions = await buildEnhancedInstructions();
|
|
@@ -374,20 +398,27 @@ async function runInteractive() {
|
|
|
374
398
|
console.log(`\x1b[36mPlugins:\x1b[0m ${plugins.map((p) => p.manifest.name).join(', ')}`);
|
|
375
399
|
}
|
|
376
400
|
console.log('');
|
|
377
|
-
|
|
401
|
+
let shuttingDown = false;
|
|
402
|
+
async function shutdown() {
|
|
403
|
+
if (shuttingDown)
|
|
404
|
+
return;
|
|
405
|
+
shuttingDown = true;
|
|
378
406
|
const msgs = agent.getMessages();
|
|
379
407
|
if (msgs.length > 0) {
|
|
380
|
-
|
|
408
|
+
// Await the save so sessions aren't lost on SIGINT (process.exit would
|
|
409
|
+
// otherwise cut off the fire-and-forget write).
|
|
410
|
+
await saveSession(sessionId, msgs, agent.getModels()[0], undefined, costTracker.getState()).catch(() => { });
|
|
381
411
|
}
|
|
382
412
|
if (costTracker.totalTokens > 0) {
|
|
383
413
|
console.log(`\n\x1b[36mSession: ${costTracker.totalTokens.toLocaleString()} tokens${costTracker.totalCost > 0 ? ` ($${costTracker.totalCost.toFixed(4)})` : ''}\x1b[0m`);
|
|
384
414
|
}
|
|
385
415
|
console.log('Goodbye!');
|
|
386
416
|
rl.close();
|
|
387
|
-
closeBrowser().
|
|
417
|
+
await closeBrowser().catch(() => { });
|
|
418
|
+
process.exit(0);
|
|
388
419
|
}
|
|
389
|
-
process.on('SIGINT', shutdown);
|
|
390
|
-
process.on('SIGTERM', shutdown);
|
|
420
|
+
process.on('SIGINT', () => { shutdown().catch(() => process.exit(1)); });
|
|
421
|
+
process.on('SIGTERM', () => { shutdown().catch(() => process.exit(1)); });
|
|
391
422
|
const handleCommand = async (input) => {
|
|
392
423
|
const cmd = input.split(' ')[0].toLowerCase();
|
|
393
424
|
if (cmd === '/help') {
|
|
@@ -656,7 +687,7 @@ async function runInteractive() {
|
|
|
656
687
|
return true;
|
|
657
688
|
}
|
|
658
689
|
if (cmd === '/quit' || cmd === '/exit') {
|
|
659
|
-
shutdown();
|
|
690
|
+
await shutdown();
|
|
660
691
|
return true;
|
|
661
692
|
}
|
|
662
693
|
const customCmd = customCmds.find((c) => cmd === `/${c.name}`);
|
|
@@ -730,11 +761,15 @@ async function main() {
|
|
|
730
761
|
}
|
|
731
762
|
// Set up worktree isolation if requested
|
|
732
763
|
let worktreeInfo = null;
|
|
764
|
+
// Capture the original cwd BEFORE any process.chdir so cleanup can restore it
|
|
765
|
+
// and resolve the worktree parent correctly.
|
|
766
|
+
const originalCwd = process.cwd();
|
|
767
|
+
let cleanup = async () => { };
|
|
733
768
|
if (worktree) {
|
|
734
769
|
const { createWorktree, removeWorktree } = await import('./worktree.js');
|
|
735
770
|
const name = worktreeName || `session-${Date.now().toString(36)}`;
|
|
736
771
|
try {
|
|
737
|
-
worktreeInfo = await createWorktree(name,
|
|
772
|
+
worktreeInfo = await createWorktree(name, originalCwd);
|
|
738
773
|
process.chdir(worktreeInfo.path);
|
|
739
774
|
setSandboxRoot(worktreeInfo.path);
|
|
740
775
|
console.log(`Working in isolated worktree: ${worktreeInfo.branch} → ${worktreeInfo.path}`);
|
|
@@ -743,38 +778,46 @@ async function main() {
|
|
|
743
778
|
console.error(`Failed to create worktree: ${err.message}`);
|
|
744
779
|
process.exit(1);
|
|
745
780
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
781
|
+
let cleanedUp = false;
|
|
782
|
+
cleanup = async () => {
|
|
783
|
+
if (cleanedUp || !worktreeInfo)
|
|
784
|
+
return;
|
|
785
|
+
cleanedUp = true;
|
|
786
|
+
try {
|
|
787
|
+
// Return to the original directory before removing the worktree so we
|
|
788
|
+
// are not standing inside the directory being deleted.
|
|
789
|
+
process.chdir(originalCwd);
|
|
790
|
+
await removeWorktree(worktreeInfo, originalCwd);
|
|
754
791
|
}
|
|
792
|
+
catch { }
|
|
755
793
|
};
|
|
756
|
-
|
|
794
|
+
// 'exit' handlers cannot run async work — SIGINT awaits cleanup explicitly.
|
|
757
795
|
process.on('SIGINT', () => { cleanup().then(() => process.exit(0)).catch(() => process.exit(1)); });
|
|
758
|
-
process.env.SCALIX_ORIGINAL_CWD = process.cwd();
|
|
759
796
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
797
|
+
try {
|
|
798
|
+
switch (mode) {
|
|
799
|
+
case 'run':
|
|
800
|
+
if (!prompt) {
|
|
801
|
+
console.error('Error: No prompt provided. Usage: scalix run "your prompt"');
|
|
802
|
+
await cleanup();
|
|
803
|
+
process.exit(1);
|
|
804
|
+
}
|
|
805
|
+
await runSinglePrompt(prompt, output, format, outputSchema, cleanup);
|
|
806
|
+
break;
|
|
807
|
+
case 'pipe':
|
|
808
|
+
await runPipeMode(cleanup);
|
|
809
|
+
break;
|
|
810
|
+
case 'ipc':
|
|
811
|
+
await runIpcServer();
|
|
812
|
+
break;
|
|
813
|
+
case 'interactive':
|
|
814
|
+
default:
|
|
815
|
+
await runInteractive();
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
finally {
|
|
820
|
+
await cleanup();
|
|
778
821
|
}
|
|
779
822
|
}
|
|
780
823
|
main().catch((err) => {
|
package/dist/hooks.js
CHANGED
|
@@ -1,10 +1,42 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
2
|
+
import { resolve, join, basename } from 'node:path';
|
|
3
3
|
import { exec } from 'node:child_process';
|
|
4
|
-
import { promisify } from 'node:util';
|
|
5
4
|
import { logger } from './logger.js';
|
|
6
5
|
import { PROJECT_LOOKUP_DIRS, HOOK_DEFAULTS } from './constants.js';
|
|
7
|
-
|
|
6
|
+
import { sanitizeEnvForCommand } from './sandbox.js';
|
|
7
|
+
// Hook commands are written in POSIX sh syntax ($VAR, pipes, etc). On Windows
|
|
8
|
+
// the default exec shell is cmd.exe, which silently mangles them — so resolve
|
|
9
|
+
// a real POSIX shell (Git Bash) when one is available. `null` = not resolved.
|
|
10
|
+
let cachedHookShell = null;
|
|
11
|
+
function isPosixShell(shellPath) {
|
|
12
|
+
const name = basename(shellPath).toLowerCase().replace(/\.exe$/, '');
|
|
13
|
+
return name === 'bash' || name === 'sh' || name === 'zsh' || name === 'dash';
|
|
14
|
+
}
|
|
15
|
+
function resolveHookShell() {
|
|
16
|
+
if (cachedHookShell !== null)
|
|
17
|
+
return cachedHookShell;
|
|
18
|
+
if (process.platform !== 'win32') {
|
|
19
|
+
cachedHookShell = undefined;
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const candidates = [
|
|
23
|
+
process.env.SCALIX_HOOK_SHELL,
|
|
24
|
+
process.env.SHELL,
|
|
25
|
+
join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe'),
|
|
26
|
+
join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'),
|
|
27
|
+
process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe') : undefined,
|
|
28
|
+
];
|
|
29
|
+
for (const c of candidates) {
|
|
30
|
+
// Skip WSL/System32 bash implicitly: only explicit paths are checked, and
|
|
31
|
+
// non-POSIX shells (e.g. a SHELL pointing at PowerShell) are rejected.
|
|
32
|
+
if (c && isPosixShell(c) && existsSync(c)) {
|
|
33
|
+
cachedHookShell = c;
|
|
34
|
+
return c;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
cachedHookShell = undefined;
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
8
40
|
const HOOK_PATHS = PROJECT_LOOKUP_DIRS.map(d => `${d}/hooks.json`);
|
|
9
41
|
export function loadHooks(cwd = process.cwd()) {
|
|
10
42
|
for (const relPath of HOOK_PATHS) {
|
|
@@ -112,17 +144,24 @@ function parseHookOutput(stdout, exitCode) {
|
|
|
112
144
|
}
|
|
113
145
|
export async function executeHook(hook, env, stdinData) {
|
|
114
146
|
const timeout = hook.timeout ?? HOOK_DEFAULTS.timeoutMs;
|
|
115
|
-
|
|
147
|
+
// Strip secrets from the inherited environment so hook commands can't read
|
|
148
|
+
// API keys, tokens, cloud credentials, etc. The SCALIX_* context vars in
|
|
149
|
+
// `env` are still applied on top.
|
|
150
|
+
const hookEnv = { ...sanitizeEnvForCommand(process.env), ...env };
|
|
116
151
|
const ac = new AbortController();
|
|
117
152
|
const hardKill = setTimeout(() => ac.abort(), timeout + 5000);
|
|
118
153
|
try {
|
|
154
|
+
const shell = resolveHookShell();
|
|
119
155
|
const child = exec(hook.command, {
|
|
120
156
|
timeout,
|
|
121
157
|
env: hookEnv,
|
|
122
158
|
maxBuffer: HOOK_DEFAULTS.maxBuffer,
|
|
123
159
|
signal: ac.signal,
|
|
160
|
+
...(shell ? { shell } : {}),
|
|
124
161
|
});
|
|
125
162
|
if (stdinData && child.stdin) {
|
|
163
|
+
// The hook may exit without reading stdin; swallow the resulting EPIPE.
|
|
164
|
+
child.stdin.on('error', () => { });
|
|
126
165
|
child.stdin.write(stdinData);
|
|
127
166
|
child.stdin.end();
|
|
128
167
|
}
|
|
@@ -132,8 +171,19 @@ export async function executeHook(hook, env, stdinData) {
|
|
|
132
171
|
child.stdout?.on('data', (d) => { stdout += d; });
|
|
133
172
|
child.stderr?.on('data', (d) => { stderr += d; });
|
|
134
173
|
child.on('error', reject);
|
|
135
|
-
child.on('close', (code) => resolve({
|
|
174
|
+
child.on('close', (code, signal) => resolve({
|
|
175
|
+
stdout,
|
|
176
|
+
stderr,
|
|
177
|
+
code: code ?? 0,
|
|
178
|
+
// A killed child reports code=null + a signal (or child.killed on
|
|
179
|
+
// Windows). We only ever kill on timeout/abort, so treat it as such —
|
|
180
|
+
// otherwise `code ?? 0` would misreport the hook as allowed.
|
|
181
|
+
killed: child.killed || signal !== null,
|
|
182
|
+
}));
|
|
136
183
|
});
|
|
184
|
+
if (result.killed) {
|
|
185
|
+
return { allowed: false, error: `Hook timed out after ${timeout}ms` };
|
|
186
|
+
}
|
|
137
187
|
return parseHookOutput(result.stdout, result.code);
|
|
138
188
|
}
|
|
139
189
|
catch (err) {
|