scalix 1.1.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/PARITY-PLAN.md +116 -0
- package/bin/scalix.js +1 -0
- package/dist/agent.js +69 -31
- package/dist/api-client.js +44 -9
- package/dist/auto-verify.js +3 -1
- package/dist/cli.js +931 -183
- package/dist/commands.js +70 -2
- package/dist/constants.js +87 -6
- 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 +116 -33
- package/dist/hooks.js +195 -13
- package/dist/injection-detector.js +18 -1
- package/dist/ipc-server.js +7 -1
- package/dist/keychain.js +222 -0
- package/dist/markdown.js +49 -3
- package/dist/model-router.js +11 -0
- package/dist/plugins.js +132 -0
- package/dist/project-index.js +20 -1
- package/dist/sandbox.js +139 -9
- package/dist/scoped-rules.js +10 -1
- package/dist/sessions.js +65 -6
- package/dist/settings.js +124 -4
- package/dist/skills.js +269 -0
- package/dist/sub-agent.js +59 -2
- package/dist/terminal-notify.js +73 -0
- package/dist/text-utils.js +146 -0
- 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 +44 -14
- package/dist/tools-web.js +76 -22
- package/dist/tools.js +27 -2
- package/dist/workspace-trust.js +90 -0
- package/package.json +3 -2
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) {
|
|
@@ -29,26 +61,130 @@ function matchesHook(hook, toolName) {
|
|
|
29
61
|
return true;
|
|
30
62
|
if (!toolName)
|
|
31
63
|
return false;
|
|
32
|
-
|
|
64
|
+
const patterns = hook.match.split('|').map(p => p.trim()).filter(Boolean);
|
|
65
|
+
return patterns.some(pattern => matchPattern(pattern, toolName));
|
|
66
|
+
}
|
|
67
|
+
function matchPattern(pattern, value) {
|
|
68
|
+
if (pattern === value)
|
|
33
69
|
return true;
|
|
34
|
-
if (
|
|
35
|
-
return
|
|
70
|
+
if (pattern === '*')
|
|
71
|
+
return true;
|
|
72
|
+
if (pattern.endsWith('*') && !pattern.startsWith('/')) {
|
|
73
|
+
return value.startsWith(pattern.slice(0, -1));
|
|
74
|
+
}
|
|
75
|
+
if (pattern.startsWith('*') && !pattern.endsWith('*')) {
|
|
76
|
+
return value.endsWith(pattern.slice(1));
|
|
77
|
+
}
|
|
78
|
+
if (pattern.startsWith('/') && pattern.endsWith('/')) {
|
|
79
|
+
try {
|
|
80
|
+
return new RegExp(pattern.slice(1, -1)).test(value);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (pattern.includes('*')) {
|
|
87
|
+
const regex = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
88
|
+
try {
|
|
89
|
+
return new RegExp(`^${regex}$`).test(value);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
36
94
|
}
|
|
37
95
|
return false;
|
|
38
96
|
}
|
|
39
|
-
|
|
97
|
+
function buildHookInput(event, context) {
|
|
98
|
+
const input = {
|
|
99
|
+
event,
|
|
100
|
+
timestamp: new Date().toISOString(),
|
|
101
|
+
};
|
|
102
|
+
if (context.toolName)
|
|
103
|
+
input.tool_name = context.toolName;
|
|
104
|
+
if (context.toolArgs) {
|
|
105
|
+
try {
|
|
106
|
+
input.tool_args = JSON.parse(context.toolArgs);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
input.tool_args = context.toolArgs;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (context.toolResult !== undefined)
|
|
113
|
+
input.tool_result = context.toolResult;
|
|
114
|
+
if (context.error)
|
|
115
|
+
input.error = context.error;
|
|
116
|
+
if (context.prompt)
|
|
117
|
+
input.prompt = context.prompt.slice(0, 4096);
|
|
118
|
+
if (context.response)
|
|
119
|
+
input.response = context.response.slice(0, 4096);
|
|
120
|
+
if (context.notification)
|
|
121
|
+
input.notification = context.notification;
|
|
122
|
+
return JSON.stringify(input);
|
|
123
|
+
}
|
|
124
|
+
function parseHookOutput(stdout, exitCode) {
|
|
125
|
+
const trimmed = stdout.trim();
|
|
126
|
+
if (trimmed.startsWith('{')) {
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(trimmed);
|
|
129
|
+
return {
|
|
130
|
+
allowed: exitCode === 0 || exitCode === undefined,
|
|
131
|
+
output: parsed.output ?? trimmed,
|
|
132
|
+
message: parsed.message,
|
|
133
|
+
data: parsed,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
catch { }
|
|
137
|
+
}
|
|
138
|
+
const allowed = exitCode === 0 || exitCode === undefined;
|
|
139
|
+
return {
|
|
140
|
+
allowed,
|
|
141
|
+
output: trimmed || undefined,
|
|
142
|
+
message: exitCode === 2 ? trimmed : undefined,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
export async function executeHook(hook, env, stdinData) {
|
|
40
146
|
const timeout = hook.timeout ?? HOOK_DEFAULTS.timeoutMs;
|
|
41
|
-
|
|
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 };
|
|
42
151
|
const ac = new AbortController();
|
|
43
152
|
const hardKill = setTimeout(() => ac.abort(), timeout + 5000);
|
|
44
153
|
try {
|
|
45
|
-
const
|
|
154
|
+
const shell = resolveHookShell();
|
|
155
|
+
const child = exec(hook.command, {
|
|
46
156
|
timeout,
|
|
47
157
|
env: hookEnv,
|
|
48
158
|
maxBuffer: HOOK_DEFAULTS.maxBuffer,
|
|
49
159
|
signal: ac.signal,
|
|
160
|
+
...(shell ? { shell } : {}),
|
|
50
161
|
});
|
|
51
|
-
|
|
162
|
+
if (stdinData && child.stdin) {
|
|
163
|
+
// The hook may exit without reading stdin; swallow the resulting EPIPE.
|
|
164
|
+
child.stdin.on('error', () => { });
|
|
165
|
+
child.stdin.write(stdinData);
|
|
166
|
+
child.stdin.end();
|
|
167
|
+
}
|
|
168
|
+
const result = await new Promise((resolve, reject) => {
|
|
169
|
+
let stdout = '';
|
|
170
|
+
let stderr = '';
|
|
171
|
+
child.stdout?.on('data', (d) => { stdout += d; });
|
|
172
|
+
child.stderr?.on('data', (d) => { stderr += d; });
|
|
173
|
+
child.on('error', reject);
|
|
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
|
+
}));
|
|
183
|
+
});
|
|
184
|
+
if (result.killed) {
|
|
185
|
+
return { allowed: false, error: `Hook timed out after ${timeout}ms` };
|
|
186
|
+
}
|
|
187
|
+
return parseHookOutput(result.stdout, result.code);
|
|
52
188
|
}
|
|
53
189
|
catch (err) {
|
|
54
190
|
if (err.killed || err.code === 'ETIMEDOUT' || err.name === 'AbortError') {
|
|
@@ -65,7 +201,25 @@ export async function executeHook(hook, env) {
|
|
|
65
201
|
}
|
|
66
202
|
}
|
|
67
203
|
export async function runHooks(hooks, event, context) {
|
|
68
|
-
const matching = hooks.filter((h) =>
|
|
204
|
+
const matching = hooks.filter((h) => {
|
|
205
|
+
if (h.event !== event)
|
|
206
|
+
return false;
|
|
207
|
+
if (!matchesHook(h, context.toolName))
|
|
208
|
+
return false;
|
|
209
|
+
if (h.type === 'prompt')
|
|
210
|
+
return false;
|
|
211
|
+
if (h.if) {
|
|
212
|
+
try {
|
|
213
|
+
const conditionMet = evaluateCondition(h.if, context);
|
|
214
|
+
if (!conditionMet)
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return true;
|
|
222
|
+
});
|
|
69
223
|
if (matching.length === 0)
|
|
70
224
|
return [];
|
|
71
225
|
const env = {};
|
|
@@ -77,10 +231,15 @@ export async function runHooks(hooks, event, context) {
|
|
|
77
231
|
env.SCALIX_ERROR = context.error;
|
|
78
232
|
if (context.prompt)
|
|
79
233
|
env.SCALIX_PROMPT = context.prompt.slice(0, 4096).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
|
|
234
|
+
if (context.response)
|
|
235
|
+
env.SCALIX_RESPONSE = context.response.slice(0, 4096);
|
|
236
|
+
if (context.notification)
|
|
237
|
+
env.SCALIX_NOTIFICATION = context.notification;
|
|
80
238
|
env.SCALIX_EVENT = event;
|
|
239
|
+
const stdinData = buildHookInput(event, context);
|
|
81
240
|
const results = [];
|
|
82
241
|
for (const hook of matching) {
|
|
83
|
-
const result = await executeHook(hook, env);
|
|
242
|
+
const result = await executeHook(hook, env, stdinData);
|
|
84
243
|
results.push(result);
|
|
85
244
|
logger.debug('Hook executed', { event, command: hook.command, allowed: result.allowed });
|
|
86
245
|
if (!result.allowed && event === 'PreToolUse')
|
|
@@ -88,13 +247,36 @@ export async function runHooks(hooks, event, context) {
|
|
|
88
247
|
}
|
|
89
248
|
return results;
|
|
90
249
|
}
|
|
250
|
+
function evaluateCondition(condition, context) {
|
|
251
|
+
if (condition.startsWith('tool_name=')) {
|
|
252
|
+
const expected = condition.slice('tool_name='.length);
|
|
253
|
+
return matchPattern(expected, context.toolName || '');
|
|
254
|
+
}
|
|
255
|
+
if (condition.startsWith('tool_args~')) {
|
|
256
|
+
const pattern = condition.slice('tool_args~'.length);
|
|
257
|
+
try {
|
|
258
|
+
return new RegExp(pattern).test(context.toolArgs || '');
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
export function getPromptHooks(hooks, event, toolName) {
|
|
267
|
+
return hooks.filter(h => h.event === event &&
|
|
268
|
+
h.type === 'prompt' &&
|
|
269
|
+
matchesHook(h, toolName));
|
|
270
|
+
}
|
|
91
271
|
export function hooksSummary(hooks) {
|
|
92
272
|
if (hooks.length === 0)
|
|
93
273
|
return 'No hooks configured.';
|
|
94
274
|
const lines = ['Configured hooks:'];
|
|
95
275
|
for (const h of hooks) {
|
|
96
276
|
const match = h.match ? ` (match: ${h.match})` : '';
|
|
97
|
-
|
|
277
|
+
const type = h.type === 'prompt' ? ' [prompt]' : '';
|
|
278
|
+
const cond = h.if ? ` [if: ${h.if}]` : '';
|
|
279
|
+
lines.push(` ${h.event}${match}${type}${cond}: ${h.command}`);
|
|
98
280
|
}
|
|
99
281
|
return lines.join('\n');
|
|
100
282
|
}
|
|
@@ -38,7 +38,24 @@ const SENSITIVITY_THRESHOLDS = {
|
|
|
38
38
|
medium: { highConfidence: 3, mediumConfidence: 2 },
|
|
39
39
|
high: { highConfidence: 2, mediumConfidence: 1 },
|
|
40
40
|
};
|
|
41
|
-
|
|
41
|
+
// Strip zero-width and other invisible/formatting characters that are used to
|
|
42
|
+
// smuggle instructions past pattern matching (zero-width space/joiner, BOM,
|
|
43
|
+
// word joiner, bidi controls, soft hyphen, etc.).
|
|
44
|
+
const INVISIBLE_CHARS_RE = new RegExp('[' +
|
|
45
|
+
'\\u00AD' + // soft hyphen
|
|
46
|
+
'\\u200B-\\u200F' + // zero-width space/non-joiner/joiner, LRM/RLM
|
|
47
|
+
'\\u202A-\\u202E' + // bidi embedding/override controls
|
|
48
|
+
'\\u2060-\\u2064' + // word joiner, invisible operators
|
|
49
|
+
'\\u206A-\\u206F' + // deprecated format controls
|
|
50
|
+
'\\uFEFF' + // BOM / zero-width no-break space
|
|
51
|
+
']', 'g');
|
|
52
|
+
function normalizeForDetection(text) {
|
|
53
|
+
// NFKC folds homoglyph/compatibility variants (e.g. fullwidth chars) to their
|
|
54
|
+
// canonical forms so evasions collapse onto the same patterns.
|
|
55
|
+
return text.normalize('NFKC').replace(INVISIBLE_CHARS_RE, '');
|
|
56
|
+
}
|
|
57
|
+
export function detectPromptInjection(rawContent, source, sensitivity = 'medium', customPatterns) {
|
|
58
|
+
const content = normalizeForDetection(rawContent);
|
|
42
59
|
const matched = [];
|
|
43
60
|
let totalWeight = 0;
|
|
44
61
|
for (const rule of ALL_PATTERNS) {
|
package/dist/ipc-server.js
CHANGED
|
@@ -21,6 +21,7 @@ import { browserTools, closeBrowser } from './tools-browser.js';
|
|
|
21
21
|
import { loadMcpServices } from './mcp.js';
|
|
22
22
|
import { loadConfig, DEFAULT_LIMITS } from './settings.js';
|
|
23
23
|
import { discoverPlugins, getPluginTools } from './plugins.js';
|
|
24
|
+
import { isWorkspaceTrusted } from './workspace-trust.js';
|
|
24
25
|
import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_API_KEY, ENV_MODEL, ENV_BASE_URL, fetchModelSpecs } from './constants.js';
|
|
25
26
|
import { loadMemory, autoDetectConventions, saveMemory } from './project-memory.js';
|
|
26
27
|
import { buildProjectContext, contextToInstructions } from './context-builder.js';
|
|
@@ -68,7 +69,12 @@ export async function runIpcServer() {
|
|
|
68
69
|
const baseUrl = process.env[ENV_BASE_URL] || config.baseUrl;
|
|
69
70
|
fetchModelSpecs(baseUrl, apiKey).catch(() => { });
|
|
70
71
|
const mcpTools = loadMcpServices();
|
|
71
|
-
|
|
72
|
+
// Only discover/load project-local plugins in a trusted workspace — plugins
|
|
73
|
+
// dynamically import and execute code from the project directory.
|
|
74
|
+
const plugins = isWorkspaceTrusted() ? await discoverPlugins(process.cwd()) : [];
|
|
75
|
+
if (!isWorkspaceTrusted()) {
|
|
76
|
+
process.stderr.write('IPC: project plugins skipped (untrusted workspace)\n');
|
|
77
|
+
}
|
|
72
78
|
const pluginTools = getPluginTools(plugins);
|
|
73
79
|
const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools, ...pluginTools];
|
|
74
80
|
const instructions = await buildInstructions();
|
package/dist/keychain.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { USER_CONFIG_DIR } from './constants.js';
|
|
7
|
+
const exec = promisify(execFile);
|
|
8
|
+
const SERVICE = 'scalix-coder';
|
|
9
|
+
const ACCOUNT = 'api-key';
|
|
10
|
+
/**
|
|
11
|
+
* SCALIX_NO_KEYCHAIN=1 disables OS keychain integration entirely — the API
|
|
12
|
+
* key stays in the config file. Intended for CI, headless environments, and
|
|
13
|
+
* hermetic tests where spawning security/secret-tool/PowerShell is unwanted.
|
|
14
|
+
*/
|
|
15
|
+
function keychainDisabled() {
|
|
16
|
+
const flag = process.env.SCALIX_NO_KEYCHAIN;
|
|
17
|
+
return flag === '1' || flag?.toLowerCase() === 'true';
|
|
18
|
+
}
|
|
19
|
+
function isWindows() {
|
|
20
|
+
return process.platform === 'win32';
|
|
21
|
+
}
|
|
22
|
+
async function isMacOS() {
|
|
23
|
+
return process.platform === 'darwin';
|
|
24
|
+
}
|
|
25
|
+
// ─── Windows DPAPI (user-scoped) via PowerShell ───
|
|
26
|
+
function windowsCredentialPath() {
|
|
27
|
+
// Resolve the home directory the same way the rest of the codebase does
|
|
28
|
+
// (settings/sessions/workspace-trust) so the credential store stays
|
|
29
|
+
// consistent and honors HOME (set by Git Bash and by tests).
|
|
30
|
+
return path.join(process.env.HOME || process.env.USERPROFILE || os.homedir() || '.', USER_CONFIG_DIR, 'credential.dat');
|
|
31
|
+
}
|
|
32
|
+
async function runPowerShell(script, stdinData) {
|
|
33
|
+
const psExe = process.env.SystemRoot
|
|
34
|
+
? path.join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
35
|
+
: 'powershell.exe';
|
|
36
|
+
const child = exec(psExe, ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
37
|
+
windowsHide: true,
|
|
38
|
+
maxBuffer: 1024 * 1024,
|
|
39
|
+
});
|
|
40
|
+
if (stdinData !== undefined) {
|
|
41
|
+
const stdin = child.child?.stdin;
|
|
42
|
+
if (!stdin)
|
|
43
|
+
throw new Error('PowerShell stdin unavailable');
|
|
44
|
+
stdin.write(stdinData);
|
|
45
|
+
stdin.end();
|
|
46
|
+
}
|
|
47
|
+
const { stdout } = await child;
|
|
48
|
+
return stdout;
|
|
49
|
+
}
|
|
50
|
+
async function setApiKeyWindows(apiKey) {
|
|
51
|
+
try {
|
|
52
|
+
const credPath = windowsCredentialPath();
|
|
53
|
+
await fs.mkdir(path.dirname(credPath), { recursive: true });
|
|
54
|
+
// Read the secret from stdin, encrypt it with user-scoped DPAPI, and print
|
|
55
|
+
// the encrypted string. Reading from stdin avoids putting the secret on the
|
|
56
|
+
// command line (where it could be seen in the process table).
|
|
57
|
+
const script = '$s = [Console]::In.ReadToEnd();' +
|
|
58
|
+
'$sec = ConvertTo-SecureString -String $s -AsPlainText -Force;' +
|
|
59
|
+
'[Console]::Out.Write((ConvertFrom-SecureString -SecureString $sec))';
|
|
60
|
+
const encrypted = (await runPowerShell(script, apiKey)).trim();
|
|
61
|
+
if (!encrypted)
|
|
62
|
+
return false;
|
|
63
|
+
await fs.writeFile(credPath, encrypted, { encoding: 'utf-8', mode: 0o600 });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function getApiKeyWindows() {
|
|
71
|
+
try {
|
|
72
|
+
const credPath = windowsCredentialPath();
|
|
73
|
+
const encrypted = (await fs.readFile(credPath, 'utf-8')).trim();
|
|
74
|
+
if (!encrypted)
|
|
75
|
+
return null;
|
|
76
|
+
// Decrypt the DPAPI blob back to plaintext via Marshal. Pass the blob over
|
|
77
|
+
// stdin to avoid quoting/length issues on the command line.
|
|
78
|
+
const script = '$enc = [Console]::In.ReadToEnd().Trim();' +
|
|
79
|
+
'$sec = ConvertTo-SecureString -String $enc;' +
|
|
80
|
+
'$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec);' +
|
|
81
|
+
'try { [Console]::Out.Write([Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr)) }' +
|
|
82
|
+
'finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) }';
|
|
83
|
+
const plaintext = await runPowerShell(script, encrypted);
|
|
84
|
+
return plaintext.trim() || null;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function deleteApiKeyWindows() {
|
|
91
|
+
try {
|
|
92
|
+
await fs.unlink(windowsCredentialPath());
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function isLinuxWithSecretTool() {
|
|
100
|
+
if (process.platform !== 'linux')
|
|
101
|
+
return false;
|
|
102
|
+
try {
|
|
103
|
+
await exec('which', ['secret-tool']);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export async function getApiKeyFromKeychain() {
|
|
111
|
+
if (keychainDisabled())
|
|
112
|
+
return null;
|
|
113
|
+
try {
|
|
114
|
+
if (isWindows()) {
|
|
115
|
+
return await getApiKeyWindows();
|
|
116
|
+
}
|
|
117
|
+
if (await isMacOS()) {
|
|
118
|
+
const { stdout } = await exec('security', [
|
|
119
|
+
'find-generic-password',
|
|
120
|
+
'-s', SERVICE,
|
|
121
|
+
'-a', ACCOUNT,
|
|
122
|
+
'-w',
|
|
123
|
+
]);
|
|
124
|
+
return stdout.trim() || null;
|
|
125
|
+
}
|
|
126
|
+
if (await isLinuxWithSecretTool()) {
|
|
127
|
+
const { stdout } = await exec('secret-tool', [
|
|
128
|
+
'lookup', 'service', SERVICE, 'account', ACCOUNT,
|
|
129
|
+
]);
|
|
130
|
+
return stdout.trim() || null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// keychain item not found or command failed
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
export async function setApiKeyInKeychain(apiKey) {
|
|
139
|
+
if (keychainDisabled())
|
|
140
|
+
return false;
|
|
141
|
+
try {
|
|
142
|
+
if (isWindows()) {
|
|
143
|
+
return await setApiKeyWindows(apiKey);
|
|
144
|
+
}
|
|
145
|
+
if (await isMacOS()) {
|
|
146
|
+
// Delete existing entry first (ignore errors if it doesn't exist)
|
|
147
|
+
try {
|
|
148
|
+
await exec('security', [
|
|
149
|
+
'delete-generic-password',
|
|
150
|
+
'-s', SERVICE,
|
|
151
|
+
'-a', ACCOUNT,
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// entry didn't exist
|
|
156
|
+
}
|
|
157
|
+
await exec('security', [
|
|
158
|
+
'add-generic-password',
|
|
159
|
+
'-s', SERVICE,
|
|
160
|
+
'-a', ACCOUNT,
|
|
161
|
+
'-w', apiKey,
|
|
162
|
+
'-U',
|
|
163
|
+
]);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
if (await isLinuxWithSecretTool()) {
|
|
167
|
+
const child = exec('secret-tool', [
|
|
168
|
+
'store',
|
|
169
|
+
'--label', 'Scalix Coder API Key',
|
|
170
|
+
'service', SERVICE,
|
|
171
|
+
'account', ACCOUNT,
|
|
172
|
+
]);
|
|
173
|
+
// secret-tool reads the secret from stdin. If stdin isn't available we
|
|
174
|
+
// cannot deliver the secret — treat that as a failure rather than
|
|
175
|
+
// silently reporting success (which would lose the key entirely).
|
|
176
|
+
const stdin = child.child?.stdin;
|
|
177
|
+
if (!stdin) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
stdin.write(apiKey);
|
|
181
|
+
stdin.end();
|
|
182
|
+
await child;
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// keychain operation failed
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
export async function deleteApiKeyFromKeychain() {
|
|
192
|
+
if (keychainDisabled())
|
|
193
|
+
return false;
|
|
194
|
+
try {
|
|
195
|
+
if (isWindows()) {
|
|
196
|
+
return await deleteApiKeyWindows();
|
|
197
|
+
}
|
|
198
|
+
if (await isMacOS()) {
|
|
199
|
+
await exec('security', [
|
|
200
|
+
'delete-generic-password',
|
|
201
|
+
'-s', SERVICE,
|
|
202
|
+
'-a', ACCOUNT,
|
|
203
|
+
]);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
if (await isLinuxWithSecretTool()) {
|
|
207
|
+
await exec('secret-tool', [
|
|
208
|
+
'clear', 'service', SERVICE, 'account', ACCOUNT,
|
|
209
|
+
]);
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// entry didn't exist or command failed
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
export async function isKeychainAvailable() {
|
|
219
|
+
if (keychainDisabled())
|
|
220
|
+
return false;
|
|
221
|
+
return isWindows() || await isMacOS() || await isLinuxWithSecretTool();
|
|
222
|
+
}
|
package/dist/markdown.js
CHANGED
|
@@ -1,13 +1,45 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { Marked } from 'marked';
|
|
3
3
|
import { markedTerminal } from 'marked-terminal';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
// This package is ESM ("type":"module"), so the CommonJS `require` global is
|
|
6
|
+
// not defined. Build one bound to this module's URL so cli-highlight loads.
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
let highlightFn = null;
|
|
9
|
+
let highlightLoaded = false;
|
|
10
|
+
function getHighlighter() {
|
|
11
|
+
if (highlightLoaded)
|
|
12
|
+
return highlightFn;
|
|
13
|
+
highlightLoaded = true;
|
|
14
|
+
try {
|
|
15
|
+
// cli-highlight is a dependency — provides 100+ language highlighting
|
|
16
|
+
const mod = require('cli-highlight');
|
|
17
|
+
highlightFn = mod.highlight;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
highlightFn = null;
|
|
21
|
+
}
|
|
22
|
+
return highlightFn;
|
|
23
|
+
}
|
|
24
|
+
function highlightCode(code, language) {
|
|
25
|
+
const hl = getHighlighter();
|
|
26
|
+
if (!hl)
|
|
27
|
+
return chalk.yellow(code);
|
|
28
|
+
try {
|
|
29
|
+
return hl(code, { language: language || undefined });
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Unknown language or highlight error — fall back to yellow
|
|
33
|
+
return chalk.yellow(code);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
4
36
|
function createMarked(width) {
|
|
5
37
|
const m = new Marked();
|
|
6
38
|
const mt = markedTerminal({
|
|
7
39
|
strong: chalk.bold,
|
|
8
40
|
em: chalk.italic,
|
|
9
41
|
codespan: chalk.cyan,
|
|
10
|
-
code:
|
|
42
|
+
code: highlightCode,
|
|
11
43
|
heading: chalk.green.bold,
|
|
12
44
|
firstHeading: chalk.magenta.bold,
|
|
13
45
|
hr: chalk.dim,
|
|
@@ -25,15 +57,29 @@ function createMarked(width) {
|
|
|
25
57
|
}
|
|
26
58
|
let cachedWidth = 0;
|
|
27
59
|
let cachedMarked = null;
|
|
60
|
+
// Content-hash render cache to avoid re-rendering identical content
|
|
61
|
+
const renderCache = new Map();
|
|
62
|
+
const MAX_CACHE_SIZE = 128;
|
|
28
63
|
export function renderMarkdown(text, cols) {
|
|
29
64
|
const width = Math.max((cols ?? process.stdout.columns ?? 100) - 6, 40);
|
|
65
|
+
const cacheKey = `${width}:${text}`;
|
|
66
|
+
const cached = renderCache.get(cacheKey);
|
|
67
|
+
if (cached)
|
|
68
|
+
return cached;
|
|
30
69
|
if (!cachedMarked || width !== cachedWidth) {
|
|
31
70
|
cachedWidth = width;
|
|
32
71
|
cachedMarked = createMarked(width);
|
|
33
72
|
}
|
|
34
73
|
try {
|
|
35
|
-
const result = cachedMarked.parse(text);
|
|
36
|
-
|
|
74
|
+
const result = cachedMarked.parse(text).replace(/\n{3,}/g, '\n\n').trim();
|
|
75
|
+
// Evict oldest entries when cache is full
|
|
76
|
+
if (renderCache.size >= MAX_CACHE_SIZE) {
|
|
77
|
+
const firstKey = renderCache.keys().next().value;
|
|
78
|
+
if (firstKey !== undefined)
|
|
79
|
+
renderCache.delete(firstKey);
|
|
80
|
+
}
|
|
81
|
+
renderCache.set(cacheKey, result);
|
|
82
|
+
return result;
|
|
37
83
|
}
|
|
38
84
|
catch {
|
|
39
85
|
return text;
|
package/dist/model-router.js
CHANGED
|
@@ -19,6 +19,17 @@ export function classifyComplexity(query, toolCallCount = 0, config) {
|
|
|
19
19
|
return 'complex';
|
|
20
20
|
return 'moderate';
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Complexity-based model selection.
|
|
24
|
+
*
|
|
25
|
+
* Intended use: a caller that owns a {@link RouterConfig} (see
|
|
26
|
+
* `createRouterConfig`/`getRouterStatus`, wired from cli.tsx) can call this per
|
|
27
|
+
* user turn to pick a cheaper/stronger model for the request. It is
|
|
28
|
+
* deliberately conservative — it only diverges from the caller's default when
|
|
29
|
+
* the router config is `enabled` (which `createRouterConfig` defaults to
|
|
30
|
+
* `false`), so importing/calling it does NOT change default behavior. When
|
|
31
|
+
* disabled, or with no tiers, it always returns `config.defaultModel`.
|
|
32
|
+
*/
|
|
22
33
|
export function selectModel(query, config, toolCallCount = 0) {
|
|
23
34
|
if (!config.enabled || config.tiers.length === 0) {
|
|
24
35
|
return config.defaultModel;
|