scalix 1.5.1 → 1.5.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/.env.example +5 -3
- package/LICENSE +0 -1
- package/README.md +47 -2
- package/dist/agent-presets.js +33 -0
- package/dist/agent.js +233 -18
- package/dist/api-client.js +4 -4
- package/dist/board-context.js +81 -0
- package/dist/browser-access.js +0 -0
- package/dist/cli.js +270 -45
- package/dist/cloud.js +1 -1
- package/dist/config.js +44 -5
- package/dist/constants.js +82 -11
- package/dist/context-builder.js +9 -1
- package/dist/feedback.js +51 -0
- package/dist/headless.js +22 -5
- package/dist/ipc-server.js +338 -108
- package/dist/keychain.js +34 -9
- package/dist/mcp.js +470 -28
- package/dist/openrouter-oauth.js +149 -0
- package/dist/project-index.js +46 -23
- package/dist/sandbox.js +115 -27
- package/dist/sessions.js +12 -0
- package/dist/slash-commands.js +6 -0
- package/dist/sub-agent.js +3 -2
- package/dist/tools-browser.js +87 -16
- package/dist/tools-extra.js +15 -3
- package/dist/tools-memory.js +74 -0
- package/dist/tools-web.js +67 -6
- package/dist/tools.js +15 -14
- package/dist/workspace-trust.js +7 -1
- package/package.json +3 -1
package/dist/project-index.js
CHANGED
|
@@ -205,42 +205,65 @@ async function extractSymbols(filePath, relativePath) {
|
|
|
205
205
|
}
|
|
206
206
|
}
|
|
207
207
|
// Import patterns for dependency tracking
|
|
208
|
-
const
|
|
209
|
-
// JS/TS: import { x } from './y' or import x from './y' or require('./y')
|
|
210
|
-
{ ext: new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']),
|
|
211
|
-
pattern: /(?:import\s+(?:{([^}]+)}\s+from\s+|(\w+)\s+from\s+)?['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/g },
|
|
212
|
-
// Python: from x import y or import x
|
|
213
|
-
{ ext: new Set(['.py']),
|
|
214
|
-
pattern: /(?:from\s+([\w.]+)\s+import\s+([\w,\s*]+)|import\s+([\w.]+))/g },
|
|
215
|
-
// Go: import "x" or import ( "x" )
|
|
216
|
-
{ ext: new Set(['.go']),
|
|
217
|
-
pattern: /import\s+(?:(?:\w+\s+)?"([^"]+)"|[\s\S]*?"([^"]+)")/g },
|
|
218
|
-
// Rust: use x::y
|
|
219
|
-
{ ext: new Set(['.rs']),
|
|
220
|
-
pattern: /use\s+([\w:]+(?:::\{[^}]+\})?)/g },
|
|
221
|
-
];
|
|
208
|
+
const JS_TS_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
|
222
209
|
function extractImports(content, relativePath, ext) {
|
|
223
210
|
const imports = [];
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const regex = new RegExp(pattern.source, pattern.flags);
|
|
211
|
+
if (JS_TS_EXTS.has(ext)) {
|
|
212
|
+
// Matches: import ... from '...'; import '...'; export ... from '...'; require('...')
|
|
213
|
+
const jsPattern = /(?:(?:import|export)\s+(?:type\s+)?(?:(?:\*\s+as\s+\w+|{([\s\S]*?)}|[\w\s,]+)\s+from\s+)?['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/g;
|
|
228
214
|
let match;
|
|
229
|
-
while ((match =
|
|
230
|
-
const modulePath = match[
|
|
215
|
+
while ((match = jsPattern.exec(content)) !== null) {
|
|
216
|
+
const modulePath = match[2] || match[3] || '';
|
|
231
217
|
if (!modulePath)
|
|
232
218
|
continue;
|
|
233
|
-
// Only track local imports (starting with . or relative paths)
|
|
234
219
|
const isLocal = modulePath.startsWith('.') || modulePath.startsWith('/');
|
|
235
220
|
if (!isLocal)
|
|
236
221
|
continue;
|
|
237
|
-
const
|
|
222
|
+
const symbolsRaw = match[1] || '';
|
|
223
|
+
const symbols = symbolsRaw
|
|
224
|
+
? symbolsRaw.split(',').map((s) => s.trim().replace(/^type\s+/, '').split(/\s+as\s+/)[0].trim()).filter(Boolean)
|
|
225
|
+
: [];
|
|
238
226
|
imports.push({
|
|
239
227
|
from: relativePath,
|
|
240
228
|
to: modulePath,
|
|
241
|
-
symbols
|
|
229
|
+
symbols,
|
|
242
230
|
});
|
|
243
231
|
}
|
|
232
|
+
return imports;
|
|
233
|
+
}
|
|
234
|
+
if (ext === '.py') {
|
|
235
|
+
const pyPattern = /(?:from\s+([\w.]+)\s+import\s+([\w,\s*]+)|import\s+([\w.]+))/g;
|
|
236
|
+
let match;
|
|
237
|
+
while ((match = pyPattern.exec(content)) !== null) {
|
|
238
|
+
const modulePath = match[1] || match[3] || '';
|
|
239
|
+
if (!modulePath || !modulePath.startsWith('.'))
|
|
240
|
+
continue;
|
|
241
|
+
const symbols = (match[2] || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
242
|
+
imports.push({ from: relativePath, to: modulePath, symbols });
|
|
243
|
+
}
|
|
244
|
+
return imports;
|
|
245
|
+
}
|
|
246
|
+
if (ext === '.go') {
|
|
247
|
+
const goPattern = /import\s+(?:(?:\w+\s+)?"([^"]+)"|[\s\S]*?"([^"]+)")/g;
|
|
248
|
+
let match;
|
|
249
|
+
while ((match = goPattern.exec(content)) !== null) {
|
|
250
|
+
const modulePath = match[1] || match[2] || '';
|
|
251
|
+
if (!modulePath || (!modulePath.startsWith('.') && !modulePath.startsWith('/')))
|
|
252
|
+
continue;
|
|
253
|
+
imports.push({ from: relativePath, to: modulePath, symbols: [] });
|
|
254
|
+
}
|
|
255
|
+
return imports;
|
|
256
|
+
}
|
|
257
|
+
if (ext === '.rs') {
|
|
258
|
+
const rsPattern = /use\s+((?:crate|super|self)::[\w:]+(?:::\{[^}]+\})?)/g;
|
|
259
|
+
let match;
|
|
260
|
+
while ((match = rsPattern.exec(content)) !== null) {
|
|
261
|
+
const modulePath = match[1] || '';
|
|
262
|
+
if (!modulePath)
|
|
263
|
+
continue;
|
|
264
|
+
imports.push({ from: relativePath, to: modulePath, symbols: [] });
|
|
265
|
+
}
|
|
266
|
+
return imports;
|
|
244
267
|
}
|
|
245
268
|
return imports;
|
|
246
269
|
}
|
package/dist/sandbox.js
CHANGED
|
@@ -15,8 +15,15 @@ function normalizeSep(p) {
|
|
|
15
15
|
return process.platform === 'win32' ? p.replace(/\//g, '\\') : p;
|
|
16
16
|
}
|
|
17
17
|
export function isWithinRoot(p, root) {
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// Case-fold only where the filesystem does. Folding on Linux made
|
|
19
|
+
// `/srv/Secret/x` test as inside `/srv/secret` — two genuinely different
|
|
20
|
+
// directories — which is an escape, not a convenience. macOS is usually
|
|
21
|
+
// case-insensitive but can be formatted otherwise; comparing exactly there
|
|
22
|
+
// can only reject a path that is really inside the root, which fails closed,
|
|
23
|
+
// and callers realpath first so the on-disk casing already agrees.
|
|
24
|
+
const fold = (s) => (IS_WINDOWS ? s.toLowerCase() : s);
|
|
25
|
+
const np = fold(normalizeSep(p));
|
|
26
|
+
const nr = fold(normalizeSep(root));
|
|
20
27
|
return np === nr || np.startsWith(nr + path.sep);
|
|
21
28
|
}
|
|
22
29
|
// Resolve symlinks on the longest existing prefix of `resolved` synchronously.
|
|
@@ -83,24 +90,44 @@ export function sandboxPath(filePath) {
|
|
|
83
90
|
}
|
|
84
91
|
return real;
|
|
85
92
|
}
|
|
93
|
+
// Async twin of realpathLongestExistingPrefix.
|
|
94
|
+
async function realpathLongestExistingPrefixAsync(resolved) {
|
|
95
|
+
let existing = resolved;
|
|
96
|
+
const remainder = [];
|
|
97
|
+
while (true) {
|
|
98
|
+
try {
|
|
99
|
+
// fs/promises has no realpath.native, so strip the prefix defensively.
|
|
100
|
+
const real = stripExtendedLengthPrefix(await fs.realpath(existing));
|
|
101
|
+
return remainder.length > 0 ? path.join(real, ...remainder) : real;
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
if (err.code !== 'ENOENT')
|
|
105
|
+
throw err;
|
|
106
|
+
const parent = path.dirname(existing);
|
|
107
|
+
if (parent === existing)
|
|
108
|
+
return resolved;
|
|
109
|
+
remainder.unshift(path.basename(existing));
|
|
110
|
+
existing = parent;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
86
114
|
export async function sandboxPathAsync(filePath) {
|
|
87
115
|
const root = getSandboxRoot();
|
|
88
116
|
const resolved = path.resolve(root, filePath);
|
|
89
117
|
if (!isWithinRoot(resolved, root)) {
|
|
90
118
|
throw new Error(`Path traversal blocked: "${filePath}" resolves outside working directory`);
|
|
91
119
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return resolved;
|
|
102
|
-
throw err;
|
|
120
|
+
// The path usually does NOT exist yet — every write of a new file lands here.
|
|
121
|
+
// Returning `resolved` unchecked on ENOENT was the hole: only the leaf has to
|
|
122
|
+
// be missing for the check to be skipped, so `<root>/link/new.txt`, where
|
|
123
|
+
// `link` is a symlink to /etc, wrote straight out of the sandbox. Resolve the
|
|
124
|
+
// longest ancestor that does exist and verify containment on that, exactly as
|
|
125
|
+
// the sync path already does.
|
|
126
|
+
const real = await realpathLongestExistingPrefixAsync(resolved);
|
|
127
|
+
if (!isWithinRoot(real, realSandboxRoot(root))) {
|
|
128
|
+
throw new Error(`Symlink traversal blocked: "${filePath}" resolves to "${real}" outside working directory`);
|
|
103
129
|
}
|
|
130
|
+
return real;
|
|
104
131
|
}
|
|
105
132
|
let _scopedRules = [];
|
|
106
133
|
export function setScopedPermissionRules(rules) {
|
|
@@ -115,6 +142,12 @@ function parseScopedToolSpec(spec) {
|
|
|
115
142
|
return { tool: match[1], prefix: match[2].replace(/\*$/, '') };
|
|
116
143
|
return { tool: spec };
|
|
117
144
|
}
|
|
145
|
+
// A prefix rule describes the command it names, not everything that can be
|
|
146
|
+
// stapled onto it. `run_command(git *)` was matching `git status; curl x | sh`
|
|
147
|
+
// with a plain startsWith, so one allow rule for a read-only command handed
|
|
148
|
+
// over arbitrary execution. Anything here means the text after the prefix is no
|
|
149
|
+
// longer the command the rule was written about.
|
|
150
|
+
const SHELL_CHAINING_RE = /[;&|<>\n\r`]|\$\(/;
|
|
118
151
|
export function checkScopedPermission(toolName, args) {
|
|
119
152
|
if (_scopedRules.length === 0)
|
|
120
153
|
return null;
|
|
@@ -125,17 +158,40 @@ export function checkScopedPermission(toolName, args) {
|
|
|
125
158
|
continue;
|
|
126
159
|
if (parsed.tool !== toolName)
|
|
127
160
|
continue;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if
|
|
131
|
-
|
|
161
|
+
const deny = rule.action === 'deny';
|
|
162
|
+
// deny is an OR over its conditions, allow is an AND. Both directions round
|
|
163
|
+
// toward refusing: a deny rule fires if anything it names matches, and an
|
|
164
|
+
// allow rule fires only if everything it names matches. Previously both
|
|
165
|
+
// were an OR that `prefix`-less rules short-circuited past entirely, so
|
|
166
|
+
// `{tool:'run_command', regex:'^git status$', action:'allow'}` allowed
|
|
167
|
+
// every command the tool could run — the regex was never consulted.
|
|
168
|
+
let prefixHit = null;
|
|
169
|
+
if (parsed.prefix) {
|
|
170
|
+
prefixHit = !!argStr && argStr.startsWith(parsed.prefix);
|
|
171
|
+
// Only allow rules need the chaining check; a deny that matched should
|
|
172
|
+
// keep matching however the command continues.
|
|
173
|
+
if (prefixHit && !deny && toolName === 'run_command'
|
|
174
|
+
&& SHELL_CHAINING_RE.test(argStr.slice(parsed.prefix.length))) {
|
|
175
|
+
prefixHit = false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
let regexHit = null;
|
|
132
179
|
if (rule.regex) {
|
|
133
180
|
try {
|
|
134
|
-
|
|
135
|
-
|
|
181
|
+
regexHit = new RegExp(rule.regex).test(argStr || '');
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// An unparseable pattern is a config error. Honouring it as a match on
|
|
185
|
+
// a deny rule over-blocks; ignoring it on an allow rule under-permits.
|
|
186
|
+
regexHit = deny;
|
|
136
187
|
}
|
|
137
|
-
catch { }
|
|
138
188
|
}
|
|
189
|
+
const conditions = [prefixHit, regexHit].filter((c) => c !== null);
|
|
190
|
+
const matched = conditions.length === 0
|
|
191
|
+
? true // bare `run_command` — the whole tool
|
|
192
|
+
: deny ? conditions.some(Boolean) : conditions.every(Boolean);
|
|
193
|
+
if (matched)
|
|
194
|
+
return rule.action;
|
|
139
195
|
}
|
|
140
196
|
return null;
|
|
141
197
|
}
|
|
@@ -264,11 +320,16 @@ export function getToolPermission(toolName, mode) {
|
|
|
264
320
|
}
|
|
265
321
|
export function getToolPermissionWithArgs(toolName, mode, args) {
|
|
266
322
|
const scoped = checkScopedPermission(toolName, args);
|
|
267
|
-
if (scoped === 'allow')
|
|
268
|
-
return 'allow';
|
|
269
323
|
if (scoped === 'deny')
|
|
270
324
|
return 'block';
|
|
271
|
-
|
|
325
|
+
const base = getToolPermission(toolName, mode);
|
|
326
|
+
// A scoped allow skips the confirmation prompt; it does not widen the mode.
|
|
327
|
+
// Returning 'allow' unconditionally let a saved `run_command(...)` rule run
|
|
328
|
+
// commands in plan mode — the one mode whose entire promise is that nothing
|
|
329
|
+
// executes. Rules earn 'ask' → 'allow', never 'block' → 'allow'.
|
|
330
|
+
if (scoped === 'allow')
|
|
331
|
+
return base === 'block' ? 'block' : 'allow';
|
|
332
|
+
return base;
|
|
272
333
|
}
|
|
273
334
|
export function isDangerousTool(toolName) {
|
|
274
335
|
const sets = getEffectiveToolSets();
|
|
@@ -409,15 +470,43 @@ const POWERSHELL_BLOCKLIST = [
|
|
|
409
470
|
/New-LocalUser\b/i,
|
|
410
471
|
/Add-LocalGroupMember\b.*Administrators/i,
|
|
411
472
|
];
|
|
473
|
+
// The Unix patterns above were written without /i, so `RM -RF /` and `Shutdown`
|
|
474
|
+
// walked past them — and on macOS and Windows, where the filesystem is
|
|
475
|
+
// case-insensitive, those spellings run exactly like the lower-case ones.
|
|
476
|
+
// Adding the flag here rather than to each literal keeps `re.source` identical,
|
|
477
|
+
// so `security.disabledBlockedCommands` entries users have already written
|
|
478
|
+
// still match the pattern they were meant to disable.
|
|
479
|
+
const ignoreCase = (re) => re.flags.includes('i') ? re : new RegExp(re.source, `${re.flags}i`);
|
|
412
480
|
const COMMAND_BLOCKLIST = [
|
|
413
481
|
...UNIX_BLOCKLIST,
|
|
414
482
|
...WINDOWS_BLOCKLIST,
|
|
415
483
|
...POWERSHELL_BLOCKLIST,
|
|
484
|
+
].map(ignoreCase);
|
|
485
|
+
// Command substitution — `` `x` `` and `$(x)` — used to be flagged outright, on
|
|
486
|
+
// either platform. But substitution is ordinary shell, not an attack: `git tag
|
|
487
|
+
// v$(node -p "require('./package.json').version")` and `Write-Host "$($x.Name)"`
|
|
488
|
+
// were both hard-blocked, and the documented way out was to switch injection
|
|
489
|
+
// detection off wholesale, which costs far more than it saves. So flag a
|
|
490
|
+
// substitution only when its body carries a payload worth a second look.
|
|
491
|
+
//
|
|
492
|
+
// Little coverage is lost: none of these patterns are anchored to the outside
|
|
493
|
+
// of a substitution, so `$(curl x | sh)` still trips the pipe-to-shell rule and
|
|
494
|
+
// `$(rm -rf /)` still trips the blocklist. What changes is that a substitution
|
|
495
|
+
// is no longer suspicious purely for existing.
|
|
496
|
+
//
|
|
497
|
+
// Note: this replaces the `` `[^`]*` `` and `\$\([^)]*\)` sources, so any
|
|
498
|
+
// `security.disabledInjectionPatterns` entry naming those is now a no-op.
|
|
499
|
+
const SUBSTITUTION_PAYLOAD = String.raw `\b(?:rm|mkfs|dd|shutdown|reboot|curl|wget|nc|ncat|telnet|chmod|chown`
|
|
500
|
+
+ String.raw `|eval|exec|base64|xxd|sudo|doas|sh|bash|zsh|fish|dash|ksh`
|
|
501
|
+
+ String.raw `|python[23]?|perl|ruby|php|iex|Invoke-Expression|powershell|pwsh|cmd`
|
|
502
|
+
+ String.raw `|certutil|bitsadmin|mshta|rundll32)\b|/etc/|id_rsa|\.ssh\b`;
|
|
503
|
+
const SUBSTITUTION_PATTERNS = [
|
|
504
|
+
new RegExp('`[^`]*(?:' + SUBSTITUTION_PAYLOAD + ')[^`]*`', 'i'),
|
|
505
|
+
new RegExp('\\$\\([^)]*(?:' + SUBSTITUTION_PAYLOAD + ')[^)]*\\)', 'i'),
|
|
416
506
|
];
|
|
417
507
|
// Unix injection patterns
|
|
418
508
|
const UNIX_INJECTION_PATTERNS = [
|
|
419
|
-
|
|
420
|
-
/\$\([^)]*\)/,
|
|
509
|
+
...SUBSTITUTION_PATTERNS,
|
|
421
510
|
/;\s*(rm|mkfs|dd|shutdown|reboot|curl|wget)\b/,
|
|
422
511
|
/\|\s*(ba)?sh\b/,
|
|
423
512
|
/\beval\s+/,
|
|
@@ -430,7 +519,6 @@ const UNIX_INJECTION_PATTERNS = [
|
|
|
430
519
|
// Windows/PowerShell injection patterns
|
|
431
520
|
const WINDOWS_INJECTION_PATTERNS = [
|
|
432
521
|
/&\s*\{[^}]*\}/,
|
|
433
|
-
/\$\([^)]*\)/,
|
|
434
522
|
/;\s*(del|rd|rmdir|format|diskpart|net\s+user|reg\s+delete)\b/i,
|
|
435
523
|
/\|\s*powershell\b/i,
|
|
436
524
|
/\|\s*pwsh\b/i,
|
|
@@ -445,7 +533,7 @@ const WINDOWS_INJECTION_PATTERNS = [
|
|
|
445
533
|
const INJECTION_PATTERNS = [
|
|
446
534
|
...UNIX_INJECTION_PATTERNS,
|
|
447
535
|
...WINDOWS_INJECTION_PATTERNS,
|
|
448
|
-
];
|
|
536
|
+
].map(ignoreCase);
|
|
449
537
|
const SENSITIVE_ENV_PATTERNS = [
|
|
450
538
|
/API_KEY/i, /SECRET/i, /TOKEN/i, /PASSWORD/i, /CREDENTIAL/i, /AUTH/i, /PRIVATE/i,
|
|
451
539
|
// Broad substrings that commonly carry secrets or connection strings.
|
package/dist/sessions.js
CHANGED
|
@@ -92,6 +92,18 @@ export async function listSessions() {
|
|
|
92
92
|
}
|
|
93
93
|
return sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
94
94
|
}
|
|
95
|
+
export async function renameSession(id, newName) {
|
|
96
|
+
const session = await loadSession(id);
|
|
97
|
+
const trimmed = newName.trim();
|
|
98
|
+
if (trimmed) {
|
|
99
|
+
session.meta.name = trimmed;
|
|
100
|
+
session.meta.updatedAt = new Date().toISOString();
|
|
101
|
+
}
|
|
102
|
+
const tmpPath = path.join(SESSIONS_DIR, `scalix-session-${id}-${Date.now()}-${randomUUID().slice(0, 8)}.tmp`);
|
|
103
|
+
await fs.writeFile(tmpPath, JSON.stringify(session, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
104
|
+
await fs.rename(tmpPath, sessionPath(id));
|
|
105
|
+
return session.meta;
|
|
106
|
+
}
|
|
95
107
|
export async function deleteSession(id) {
|
|
96
108
|
try {
|
|
97
109
|
await fs.unlink(sessionPath(id));
|
package/dist/slash-commands.js
CHANGED
|
@@ -16,16 +16,19 @@ export const SLASH_COMMANDS = [
|
|
|
16
16
|
{ cmd: '/mode', desc: 'Permission mode (plan/default/auto/yolo)' },
|
|
17
17
|
{ cmd: '/thinking', desc: 'Thinking mode (off/low/medium/high)' },
|
|
18
18
|
{ cmd: '/plan', desc: 'Enter plan mode (read-only)' },
|
|
19
|
+
{ cmd: '/preset', desc: 'Set agent preset (persona)' },
|
|
19
20
|
{ cmd: '/compact', desc: 'Compress conversation context' },
|
|
20
21
|
{ cmd: '/context', desc: 'Show context usage & token count' },
|
|
21
22
|
{ cmd: '/cost', desc: 'Show token usage & cost' },
|
|
22
23
|
{ cmd: '/diff', desc: 'Show uncommitted changes' },
|
|
23
24
|
{ cmd: '/copy', desc: 'Copy last response to clipboard' },
|
|
24
25
|
{ cmd: '/save', desc: 'Save current session' },
|
|
26
|
+
{ cmd: '/rename', desc: 'Rename current chat session' },
|
|
25
27
|
{ cmd: '/load', desc: 'Resume a saved session' },
|
|
26
28
|
{ cmd: '/resume', desc: 'Resume from crash recovery or saved session' },
|
|
27
29
|
{ cmd: '/fork', desc: 'Fork conversation from a point in history' },
|
|
28
30
|
{ cmd: '/export', desc: 'Export conversation as text' },
|
|
31
|
+
{ cmd: '/feedback', desc: 'Submit user feedback & ratings' },
|
|
29
32
|
{ cmd: '/index', desc: 'Scan & index project files' },
|
|
30
33
|
{ cmd: '/init', desc: 'Initialize project config (SCALIX.md)' },
|
|
31
34
|
{ cmd: '/git', desc: 'Show git context' },
|
|
@@ -69,6 +72,7 @@ export const HELP_LINES = [
|
|
|
69
72
|
' /shortcuts \u2014 keyboard shortcuts',
|
|
70
73
|
' /clear \u2014 reset conversation',
|
|
71
74
|
' /save [name] \u2014 save current session',
|
|
75
|
+
' /rename [name]\u2014 rename current session',
|
|
72
76
|
' /load \u2014 resume a saved session',
|
|
73
77
|
' /resume \u2014 resume from crash recovery or saved session',
|
|
74
78
|
' /fork \u2014 fork conversation from a point in history',
|
|
@@ -81,6 +85,7 @@ export const HELP_LINES = [
|
|
|
81
85
|
' /mode [m] \u2014 permission mode (plan/default/auto/yolo)',
|
|
82
86
|
' /thinking [m] \u2014 thinking mode (off/low/medium/high)',
|
|
83
87
|
' /plan \u2014 enter plan mode (read-only)',
|
|
88
|
+
' /preset [p] \u2014 set agent preset (cloud-architect/dba/devops/full-access)',
|
|
84
89
|
'',
|
|
85
90
|
'Context & Cost:',
|
|
86
91
|
' /compact \u2014 compress conversation context',
|
|
@@ -109,6 +114,7 @@ export const HELP_LINES = [
|
|
|
109
114
|
'',
|
|
110
115
|
'Tools & Config:',
|
|
111
116
|
' /copy \u2014 copy last response to clipboard',
|
|
117
|
+
' /feedback [m] \u2014 submit user feedback',
|
|
112
118
|
' /cloud \u2014 Scalix Cloud status (/cloud tools, /cloud refresh)',
|
|
113
119
|
' /login \u2014 link your Scalix account (API key)',
|
|
114
120
|
' /status \u2014 show version, model & connection info',
|
package/dist/sub-agent.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createAgent } from './agent.js';
|
|
2
2
|
import { defaultTools } from './tools.js';
|
|
3
3
|
import { extraTools } from './tools-extra.js';
|
|
4
|
+
import { memoryTools } from './tools-memory.js';
|
|
4
5
|
import { webTools } from './tools-web.js';
|
|
5
6
|
import { advancedTools } from './tools-advanced.js';
|
|
6
7
|
import { logger } from './logger.js';
|
|
@@ -15,10 +16,10 @@ import { createWorktree, removeWorktree, mergeWorktree } from './worktree.js';
|
|
|
15
16
|
const READ_ONLY_TOOL_NAMES = new Set([
|
|
16
17
|
'get_current_time', 'calculate', 'read_file', 'read_file_lines',
|
|
17
18
|
'list_files', 'search_files', 'find_files', 'tree',
|
|
18
|
-
'git_status', 'git_diff', 'web_search', 'monitor',
|
|
19
|
+
'git_status', 'git_diff', 'web_search', 'monitor', 'get_memory',
|
|
19
20
|
]);
|
|
20
21
|
function selectTools(level, allowAgentTool = false) {
|
|
21
|
-
const all = [...defaultTools, ...extraTools, ...webTools, ...advancedTools]
|
|
22
|
+
const all = [...defaultTools, ...extraTools, ...memoryTools, ...webTools, ...advancedTools]
|
|
22
23
|
.filter((t) => {
|
|
23
24
|
const name = t.function?.name || t.name;
|
|
24
25
|
if (name === 'agent' && !allowAgentTool)
|
package/dist/tools-browser.js
CHANGED
|
@@ -4,9 +4,11 @@ import fs from 'node:fs/promises';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { sandboxPath, getSandboxRoot } from './sandbox.js';
|
|
6
6
|
import { isPrivateHost } from './constants.js';
|
|
7
|
+
import { checkLocalAccess, isLocalOriginApproved } from './browser-access.js';
|
|
7
8
|
import { encodeImageToDataUrl } from './tools-vision.js';
|
|
8
|
-
// Validate a navigation target before handing it to Playwright:
|
|
9
|
-
//
|
|
9
|
+
// Validate a navigation target's shape before handing it to Playwright:
|
|
10
|
+
// http/https only. Whether a *local* target is reachable is a separate,
|
|
11
|
+
// user-answerable question — see checkLocalAccess in browser-access.ts.
|
|
10
12
|
// Returns an error string when the URL must be rejected, otherwise null.
|
|
11
13
|
function validateNavigationUrl(rawUrl) {
|
|
12
14
|
let parsed;
|
|
@@ -19,18 +21,17 @@ function validateNavigationUrl(rawUrl) {
|
|
|
19
21
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
20
22
|
return `Blocked: only http/https URLs are allowed (got ${parsed.protocol})`;
|
|
21
23
|
}
|
|
22
|
-
if (isPrivateHost(parsed.hostname)) {
|
|
23
|
-
return 'Blocked: navigation to localhost and private networks is not allowed';
|
|
24
|
-
}
|
|
25
24
|
return null;
|
|
26
25
|
}
|
|
27
26
|
// Re-check the URL a page actually landed on (post-redirect) and reject when it
|
|
28
|
-
// resolved to a private host.
|
|
27
|
+
// resolved to a private host the user has not cleared. A redirect is not a
|
|
28
|
+
// moment to interrupt with a prompt, so an unapproved local origin is simply
|
|
29
|
+
// refused here — the model can navigate to it directly and be asked properly.
|
|
29
30
|
function validateResolvedUrl(rawUrl) {
|
|
30
31
|
try {
|
|
31
32
|
const parsed = new URL(rawUrl);
|
|
32
33
|
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
|
33
|
-
if (isPrivateHost(parsed.hostname)) {
|
|
34
|
+
if (isPrivateHost(parsed.hostname) && !isLocalOriginApproved(rawUrl)) {
|
|
34
35
|
return 'Blocked: navigation redirected to a private network address';
|
|
35
36
|
}
|
|
36
37
|
}
|
|
@@ -40,6 +41,27 @@ function validateResolvedUrl(rawUrl) {
|
|
|
40
41
|
}
|
|
41
42
|
return null;
|
|
42
43
|
}
|
|
44
|
+
let frameSink = null;
|
|
45
|
+
/** Front ends that can display the agent's browser register here. */
|
|
46
|
+
export function setBrowserFrameSink(fn) {
|
|
47
|
+
frameSink = fn;
|
|
48
|
+
}
|
|
49
|
+
// Capture is best-effort and must never turn a working action into a failed
|
|
50
|
+
// one: a page that navigated away mid-shot is a missing frame, not an error.
|
|
51
|
+
async function emitFrame(page, action) {
|
|
52
|
+
if (!frameSink)
|
|
53
|
+
return;
|
|
54
|
+
try {
|
|
55
|
+
const buf = await page.screenshot({ type: 'jpeg', quality: 55 });
|
|
56
|
+
frameSink({
|
|
57
|
+
action,
|
|
58
|
+
url: page.url(),
|
|
59
|
+
title: await page.title().catch(() => ''),
|
|
60
|
+
dataUrl: `data:image/jpeg;base64,${buf.toString('base64')}`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch { }
|
|
64
|
+
}
|
|
43
65
|
let browserInstance = null;
|
|
44
66
|
let browserContext = null;
|
|
45
67
|
let browserPage = null;
|
|
@@ -76,12 +98,30 @@ async function ensureBrowser() {
|
|
|
76
98
|
browserPage = null;
|
|
77
99
|
}
|
|
78
100
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
101
|
+
try {
|
|
102
|
+
browserInstance = await pw.chromium.launch({ headless: true });
|
|
103
|
+
browserContext = await browserInstance.newContext({
|
|
104
|
+
viewport: { width: 1280, height: 720 },
|
|
105
|
+
});
|
|
106
|
+
browserPage = await browserContext.newPage();
|
|
107
|
+
return { browser: browserInstance, page: browserPage };
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
if (browserInstance) {
|
|
111
|
+
try {
|
|
112
|
+
await browserInstance.close();
|
|
113
|
+
}
|
|
114
|
+
catch { }
|
|
115
|
+
browserInstance = null;
|
|
116
|
+
}
|
|
117
|
+
browserContext = null;
|
|
118
|
+
browserPage = null;
|
|
119
|
+
const msg = err?.message || String(err);
|
|
120
|
+
if (msg.includes('Executable doesn\'t exist') || msg.includes('playwright install') || msg.includes('chromium')) {
|
|
121
|
+
throw new Error(`Playwright browser binaries not found. Run 'npx playwright install chromium' to enable browser automation.`);
|
|
122
|
+
}
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
85
125
|
}
|
|
86
126
|
export async function closeBrowser() {
|
|
87
127
|
if (browserContext) {
|
|
@@ -111,6 +151,11 @@ export const browserNavigateTool = tool({
|
|
|
111
151
|
const preCheck = validateNavigationUrl(url);
|
|
112
152
|
if (preCheck)
|
|
113
153
|
return { error: preCheck };
|
|
154
|
+
// localhost and the LAN need the user's say-so, once per origin per
|
|
155
|
+
// project. This is what lets the agent open the dev server it just edited.
|
|
156
|
+
const localGate = await checkLocalAccess(url);
|
|
157
|
+
if (localGate)
|
|
158
|
+
return { error: localGate };
|
|
114
159
|
const session = await ensureBrowser();
|
|
115
160
|
if (!session) {
|
|
116
161
|
return { error: 'Playwright not available. Install it: npm install playwright && npx playwright install chromium' };
|
|
@@ -130,6 +175,7 @@ export const browserNavigateTool = tool({
|
|
|
130
175
|
return { error: postCheck, url: currentUrl };
|
|
131
176
|
}
|
|
132
177
|
const title = await session.page.title();
|
|
178
|
+
await emitFrame(session.page, `navigate ${currentUrl}`);
|
|
133
179
|
return {
|
|
134
180
|
url: currentUrl,
|
|
135
181
|
title,
|
|
@@ -228,7 +274,7 @@ export const browserScreenshotTool = tool({
|
|
|
228
274
|
overlay.remove();
|
|
229
275
|
});
|
|
230
276
|
}
|
|
231
|
-
|
|
277
|
+
const shot = {
|
|
232
278
|
url: session.page.url(),
|
|
233
279
|
title: await session.page.title(),
|
|
234
280
|
path: outputPath || undefined,
|
|
@@ -236,6 +282,11 @@ export const browserScreenshotTool = tool({
|
|
|
236
282
|
dataUrl,
|
|
237
283
|
annotations,
|
|
238
284
|
};
|
|
285
|
+
// Reuse the shot the model is about to look at rather than taking a
|
|
286
|
+
// second one — the annotated overlay is exactly what is worth showing.
|
|
287
|
+
if (frameSink)
|
|
288
|
+
frameSink({ action: 'screenshot', url: shot.url, title: shot.title, dataUrl });
|
|
289
|
+
return shot;
|
|
239
290
|
}
|
|
240
291
|
catch (error) {
|
|
241
292
|
return { error: `Screenshot failed: ${error.message}` };
|
|
@@ -273,6 +324,7 @@ export const browserClickTool = tool({
|
|
|
273
324
|
}
|
|
274
325
|
await session.page.waitForTimeout(500);
|
|
275
326
|
const target = typeof x === 'number' ? `coords:(${x},${y})` : selector || `text:"${text}"`;
|
|
327
|
+
await emitFrame(session.page, `click ${target}`);
|
|
276
328
|
return { clicked: target, url: session.page.url() };
|
|
277
329
|
}
|
|
278
330
|
catch (error) {
|
|
@@ -323,6 +375,7 @@ export const browserMouseTool = tool({
|
|
|
323
375
|
await session.page.waitForTimeout(300);
|
|
324
376
|
break;
|
|
325
377
|
}
|
|
378
|
+
await emitFrame(session.page, `${action} (${x},${y})`);
|
|
326
379
|
return { action, x, y, url: session.page.url() };
|
|
327
380
|
}
|
|
328
381
|
catch (error) {
|
|
@@ -355,6 +408,7 @@ export const browserTypeTool = tool({
|
|
|
355
408
|
await session.page.press(selector, 'Enter');
|
|
356
409
|
await session.page.waitForTimeout(500);
|
|
357
410
|
}
|
|
411
|
+
await emitFrame(session.page, `type into ${selector}`);
|
|
358
412
|
return { typed: text.length > 50 ? text.slice(0, 50) + '...' : text, selector, url: session.page.url() };
|
|
359
413
|
}
|
|
360
414
|
catch (error) {
|
|
@@ -489,7 +543,16 @@ export const browserSessionRestoreTool = tool({
|
|
|
489
543
|
browserPage = null;
|
|
490
544
|
}
|
|
491
545
|
if (!browserInstance) {
|
|
492
|
-
|
|
546
|
+
try {
|
|
547
|
+
browserInstance = await pw.chromium.launch({ headless: true });
|
|
548
|
+
}
|
|
549
|
+
catch (err) {
|
|
550
|
+
const msg = err?.message || String(err);
|
|
551
|
+
if (msg.includes('Executable doesn\'t exist') || msg.includes('playwright install') || msg.includes('chromium')) {
|
|
552
|
+
return { error: `Session restore failed: Playwright browser binaries not found. Run 'npx playwright install chromium' to enable browser automation.` };
|
|
553
|
+
}
|
|
554
|
+
return { error: `Session restore failed: ${msg}` };
|
|
555
|
+
}
|
|
493
556
|
}
|
|
494
557
|
browserContext = await browserInstance.newContext({
|
|
495
558
|
viewport: session.viewport,
|
|
@@ -497,7 +560,11 @@ export const browserSessionRestoreTool = tool({
|
|
|
497
560
|
});
|
|
498
561
|
browserPage = await browserContext.newPage();
|
|
499
562
|
if (session.url && session.url !== 'about:blank') {
|
|
500
|
-
const preCheck = validateNavigationUrl(session.url)
|
|
563
|
+
const preCheck = validateNavigationUrl(session.url)
|
|
564
|
+
// A saved session is a stored URL replayed without the model naming
|
|
565
|
+
// it, so a local target still has to clear the same consent gate as
|
|
566
|
+
// a fresh navigation.
|
|
567
|
+
|| await checkLocalAccess(session.url);
|
|
501
568
|
if (preCheck) {
|
|
502
569
|
return { error: `Session restore blocked: saved URL rejected — ${preCheck}` };
|
|
503
570
|
}
|
|
@@ -575,6 +642,10 @@ export const browserCloseTool = tool({
|
|
|
575
642
|
}
|
|
576
643
|
catch { }
|
|
577
644
|
await closeBrowser();
|
|
645
|
+
// An empty frame means "the agent's browser is gone" — the viewer keeps the
|
|
646
|
+
// last picture but stops implying it is live.
|
|
647
|
+
if (frameSink)
|
|
648
|
+
frameSink({ action: 'closed', url: '', title: '', dataUrl: '' });
|
|
578
649
|
return { status: 'closed', sessionSaved: saveSession || undefined };
|
|
579
650
|
},
|
|
580
651
|
});
|
package/dist/tools-extra.js
CHANGED
|
@@ -160,9 +160,21 @@ export const searchFilesTool = tool({
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
// Fallback: built-in search using fast-glob + regex
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
let searchCwd = cwd;
|
|
164
|
+
let targetPattern = fileGlob || '**/*';
|
|
165
|
+
if (searchPath) {
|
|
166
|
+
const safeTarget = sandboxPath(searchPath);
|
|
167
|
+
const st = await fs.stat(safeTarget).catch(() => null);
|
|
168
|
+
if (st && !st.isDirectory()) {
|
|
169
|
+
searchCwd = path.dirname(safeTarget);
|
|
170
|
+
targetPattern = path.basename(safeTarget);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
searchCwd = safeTarget;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const files = await fg(targetPattern, {
|
|
177
|
+
cwd: searchCwd,
|
|
166
178
|
ignore: ['node_modules/**', '.git/**', 'dist/**', 'coverage/**', '*.lock'],
|
|
167
179
|
absolute: true,
|
|
168
180
|
onlyFiles: true,
|