scalix 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cloud.js ADDED
@@ -0,0 +1,236 @@
1
+ // Scalix Cloud platform client — identity (/v1/me), the account's MCP tool
2
+ // catalog (/api/v1/mcp), and tool execution (/api/v1/mcp/call).
3
+ //
4
+ // Wire-format notes (verified against the gateway + mcp-server crates):
5
+ // - Catalog tool entries serialize the schema under `input_schema` (snake_case,
6
+ // serde default on McpToolDef in both crates/gateway/src/mcp.rs and
7
+ // crates/mcp-server/src/types.rs). The MCP JSON-RPC spec spells it
8
+ // `inputSchema`; we tolerate both so a future spec-aligned rename is a no-op.
9
+ // - Tool call results are `{content: [{type: 'text', text}], is_error}`
10
+ // (McpToolResult/McpContent — only text blocks are ever produced). `isError`
11
+ // is tolerated for the same forward-compat reason.
12
+ // - Error envelope on 4xx/5xx: `{error, code, request_id?, docs_url?}`
13
+ // (ApiError::into_response in crates/gateway/src/server.rs).
14
+ //
15
+ // Dependency-free: global fetch only. Reuses apiHostRoot() so legacy configs
16
+ // with a trailing /v1 keep working for these non-AI endpoints too.
17
+ import { apiHostRoot, USER_AGENT, WEB_DEFAULTS } from './constants.js';
18
+ export const CONSOLE_KEYS_URL = 'https://console.scalix.world/org/keys';
19
+ export const CLOUD_DEFAULTS = {
20
+ fetchTimeoutMs: WEB_DEFAULTS.apiFetchTimeoutMs,
21
+ // Platform operations (deploys, migrations) can legitimately take a while.
22
+ callTimeoutMs: 120_000,
23
+ };
24
+ export class CloudError extends Error {
25
+ status;
26
+ code;
27
+ docsUrl;
28
+ requestId;
29
+ constructor(message, opts = {}) {
30
+ super(message);
31
+ this.name = 'CloudError';
32
+ this.status = opts.status;
33
+ this.code = opts.code;
34
+ this.docsUrl = opts.docsUrl;
35
+ this.requestId = opts.requestId;
36
+ }
37
+ }
38
+ // ─── Pure parsers (unit-tested without network) ───
39
+ /**
40
+ * Build a CloudError from a non-OK response body using the gateway's error
41
+ * envelope `{error, code, docs_url, request_id}`. Falls back to the raw body
42
+ * (or status text) when the body isn't the envelope — the message is always
43
+ * whatever the server actually said, never an invented one.
44
+ */
45
+ export function errorFromEnvelope(status, statusText, bodyText) {
46
+ let envelope = null;
47
+ try {
48
+ const parsed = JSON.parse(bodyText);
49
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
50
+ envelope = parsed;
51
+ }
52
+ catch { }
53
+ if (status === 401) {
54
+ return new CloudError(`API key rejected — create one at ${CONSOLE_KEYS_URL}`, {
55
+ status,
56
+ code: typeof envelope?.code === 'string' ? envelope.code : undefined,
57
+ docsUrl: typeof envelope?.docs_url === 'string' ? envelope.docs_url : undefined,
58
+ requestId: typeof envelope?.request_id === 'string' ? envelope.request_id : undefined,
59
+ });
60
+ }
61
+ const message = typeof envelope?.error === 'string' && envelope.error
62
+ ? envelope.error
63
+ : (bodyText.trim().slice(0, 200) || `${status} ${statusText}`);
64
+ return new CloudError(message, {
65
+ status,
66
+ code: typeof envelope?.code === 'string' ? envelope.code : undefined,
67
+ docsUrl: typeof envelope?.docs_url === 'string' ? envelope.docs_url : undefined,
68
+ requestId: typeof envelope?.request_id === 'string' ? envelope.request_id : undefined,
69
+ });
70
+ }
71
+ /** Parse the `GET /v1/me` response. Throws CloudError on a malformed body. */
72
+ export function parseMeResponse(data) {
73
+ const d = data;
74
+ const identity = d?.identity;
75
+ const permissions = d?.permissions;
76
+ const limits = d?.limits;
77
+ if (!identity || typeof identity.org_id !== 'string'
78
+ || !permissions || !Array.isArray(permissions.scopes)
79
+ || !limits || typeof limits.plan !== 'string') {
80
+ throw new CloudError('Unexpected /v1/me response shape — the endpoint did not return identity/permissions/limits');
81
+ }
82
+ return {
83
+ identity: {
84
+ keyId: String(identity.key_id ?? ''),
85
+ tokenType: String(identity.token_type ?? ''),
86
+ orgId: identity.org_id,
87
+ projectId: typeof identity.project_id === 'string' && identity.project_id ? identity.project_id : undefined,
88
+ createdBy: String(identity.created_by ?? ''),
89
+ },
90
+ permissions: {
91
+ role: String(permissions.role ?? ''),
92
+ scopes: permissions.scopes.filter((s) => typeof s === 'string'),
93
+ canCreateKeys: permissions.can_create_keys === true,
94
+ canCreateProjects: permissions.can_create_projects === true,
95
+ },
96
+ tools: Array.isArray(d.tools) ? d.tools.filter((t) => typeof t === 'string') : [],
97
+ limits: {
98
+ plan: limits.plan,
99
+ rateLimitRps: typeof limits.rate_limit_rps === 'number' ? limits.rate_limit_rps : 0,
100
+ apiKeysMax: typeof limits.api_keys_max === 'number' ? limits.api_keys_max : 0,
101
+ },
102
+ };
103
+ }
104
+ /**
105
+ * Parse the `GET /api/v1/mcp` catalog. Entries without a name or an object
106
+ * schema are dropped (the gateway guarantees both; we don't register a tool we
107
+ * can't describe to the model). Throws CloudError when the body isn't a catalog.
108
+ */
109
+ export function parseCatalogResponse(data) {
110
+ const d = data;
111
+ const rawTools = d?.capabilities?.tools;
112
+ if (!Array.isArray(rawTools)) {
113
+ throw new CloudError('Unexpected /api/v1/mcp response shape — no capabilities.tools array');
114
+ }
115
+ const tools = [];
116
+ for (const entry of rawTools) {
117
+ if (!entry || typeof entry.name !== 'string' || !entry.name)
118
+ continue;
119
+ const schema = entry.input_schema ?? entry.inputSchema;
120
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema))
121
+ continue;
122
+ tools.push({
123
+ name: entry.name,
124
+ description: typeof entry.description === 'string' ? entry.description : '',
125
+ inputSchema: schema,
126
+ });
127
+ }
128
+ return { tools, platformAvailable: d.platform_tools_available === true };
129
+ }
130
+ /**
131
+ * Normalize a `{content, is_error}` tool result to plain text for the agent.
132
+ * Content is an array of `{type: 'text', text}` blocks (the only block type the
133
+ * gateway produces); a bare string is accepted too. Throws CloudError when the
134
+ * body carries no readable content at all.
135
+ */
136
+ export function parseToolCallResult(data) {
137
+ const d = data;
138
+ if (!d || typeof d !== 'object') {
139
+ throw new CloudError('Unexpected tool call response — not an object');
140
+ }
141
+ const isError = d.is_error === true || d.isError === true;
142
+ let text;
143
+ if (typeof d.content === 'string') {
144
+ text = d.content;
145
+ }
146
+ else if (Array.isArray(d.content)) {
147
+ text = d.content
148
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
149
+ .map((b) => b.text)
150
+ .join('\n');
151
+ }
152
+ else {
153
+ throw new CloudError('Unexpected tool call response — no content field');
154
+ }
155
+ return { ok: !isError, text };
156
+ }
157
+ // ─── HTTP calls ───
158
+ function cloudHeaders(apiKey, projectId) {
159
+ const headers = {
160
+ 'Authorization': `Bearer ${apiKey}`,
161
+ 'User-Agent': USER_AGENT,
162
+ 'x-client-type': 'cli',
163
+ };
164
+ if (projectId)
165
+ headers['x-scalix-project'] = projectId;
166
+ return headers;
167
+ }
168
+ /**
169
+ * Fetch the caller's identity, plan, and platform capabilities from `/v1/me`.
170
+ * Throws CloudError (with the gateway's own `error` text) on any failure.
171
+ */
172
+ export async function fetchMe(baseUrl, apiKey) {
173
+ const url = `${apiHostRoot(baseUrl)}/v1/me`;
174
+ const res = await fetch(url, {
175
+ headers: cloudHeaders(apiKey),
176
+ signal: AbortSignal.timeout(CLOUD_DEFAULTS.fetchTimeoutMs),
177
+ });
178
+ if (!res.ok) {
179
+ const body = await res.text().catch(() => '');
180
+ throw errorFromEnvelope(res.status, res.statusText, body);
181
+ }
182
+ return parseMeResponse(await res.json());
183
+ }
184
+ /**
185
+ * Fetch the account's MCP tool catalog from `/api/v1/mcp`. The catalog is
186
+ * scope-filtered server-side for the caller's key. Throws CloudError on failure.
187
+ */
188
+ export async function fetchCloudTools(baseUrl, apiKey, projectId) {
189
+ const url = `${apiHostRoot(baseUrl)}/api/v1/mcp`;
190
+ const res = await fetch(url, {
191
+ headers: cloudHeaders(apiKey, projectId),
192
+ signal: AbortSignal.timeout(CLOUD_DEFAULTS.fetchTimeoutMs),
193
+ });
194
+ if (!res.ok) {
195
+ const body = await res.text().catch(() => '');
196
+ throw errorFromEnvelope(res.status, res.statusText, body);
197
+ }
198
+ return parseCatalogResponse(await res.json());
199
+ }
200
+ /**
201
+ * Execute a platform tool via `POST /api/v1/mcp/call` and normalize the result
202
+ * to `{ok, text}`. Tool-level errors (is_error) and HTTP-level rejections (e.g.
203
+ * a read-only key calling a mutating tool) both come back as `ok: false` with
204
+ * the server's text — the agent should see tool errors as tool results, not
205
+ * exceptions. Only network/timeout failures throw.
206
+ */
207
+ export async function callCloudTool(baseUrl, apiKey, name, args, projectId) {
208
+ const url = `${apiHostRoot(baseUrl)}/api/v1/mcp/call`;
209
+ const res = await fetch(url, {
210
+ method: 'POST',
211
+ headers: { ...cloudHeaders(apiKey, projectId), 'Content-Type': 'application/json' },
212
+ body: JSON.stringify({ name, arguments: args }),
213
+ signal: AbortSignal.timeout(CLOUD_DEFAULTS.callTimeoutMs),
214
+ });
215
+ if (!res.ok) {
216
+ const body = await res.text().catch(() => '');
217
+ const err = errorFromEnvelope(res.status, res.statusText, body);
218
+ return { ok: false, text: err.docsUrl ? `${err.message} (docs: ${err.docsUrl})` : err.message };
219
+ }
220
+ return parseToolCallResult(await res.json());
221
+ }
222
+ /**
223
+ * Group tools by their platform area for display: scalix_db_query → "db",
224
+ * scalix_run_deploy → "run", non-scalix names → "other".
225
+ */
226
+ export function groupCloudTools(tools) {
227
+ const groups = new Map();
228
+ for (const t of tools) {
229
+ const m = t.name.match(/^scalix_([a-z0-9]+)_/) ?? t.name.match(/^scalix_([a-z0-9]+)$/);
230
+ const key = m ? m[1] : 'other';
231
+ const list = groups.get(key) ?? [];
232
+ list.push(t);
233
+ groups.set(key, list);
234
+ }
235
+ return groups;
236
+ }
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,7 +1,42 @@
1
- export const VERSION = '1.1.0';
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, resolve as resolvePath } from 'node:path';
4
+ // Single source of truth for the version is package.json — a hardcoded copy
5
+ // here drifted twice (1.1.0 vs 1.2.0, then 1.3.0 vs 1.3.2). Both src/ (tsx
6
+ // dev) and dist/ (build) sit one level below the package root.
7
+ function readPackageVersion() {
8
+ try {
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ const pkg = JSON.parse(readFileSync(resolvePath(here, '..', 'package.json'), 'utf-8'));
11
+ if (typeof pkg.version === 'string')
12
+ return pkg.version;
13
+ }
14
+ catch { }
15
+ return '0.0.0';
16
+ }
17
+ export const VERSION = readPackageVersion();
2
18
  export const PRODUCT_NAME = 'Scalix-Coder';
3
19
  export const USER_AGENT = `${PRODUCT_NAME}/${VERSION}`;
4
- export const DEFAULT_BASE_URL = process.env.SCALIX_BASE_URL || 'https://api.scalix.world/v1';
20
+ // Default API base: the bare Scalix host root. The client composes the full
21
+ // gateway paths itself (chat → /v1/ai/chat/completions, models → /v1/ai/models,
22
+ // audio → /v1/ai/audio/transcriptions) via aiEndpointUrl(). Override with
23
+ // SCALIX_BASE_URL or the config file's `baseUrl` for self-hosted gateways —
24
+ // an override is likewise a host root. A trailing `/v1` or `/v1/ai` (the
25
+ // pre-1.3.3 default shape, still present in older saved configs) is tolerated
26
+ // and stripped by apiHostRoot().
27
+ export const DEFAULT_BASE_URL = process.env.SCALIX_BASE_URL || 'https://api.scalix.world';
28
+ /**
29
+ * Normalize a configured base URL to the API host root: trailing slashes are
30
+ * removed, and a trailing `/v1` or `/v1/ai` segment (legacy config shape) is
31
+ * stripped so old saved configs keep working.
32
+ */
33
+ export function apiHostRoot(baseUrl) {
34
+ return baseUrl.replace(/\/+$/, '').replace(/\/v1(\/ai)?$/, '');
35
+ }
36
+ /** Compose a full URL for an endpoint under the gateway's `/v1/ai` nest. */
37
+ export function aiEndpointUrl(baseUrl, endpoint) {
38
+ return `${apiHostRoot(baseUrl)}/v1/ai/${endpoint.replace(/^\/+/, '')}`;
39
+ }
5
40
  export const DEFAULT_MODELS = [];
6
41
  export const DEFAULT_INSTRUCTIONS = `You are an expert coding assistant. You write clean, correct, production-quality code. Be concise and direct.
7
42
 
@@ -38,13 +73,13 @@ export const DEFAULT_INSTRUCTIONS = `You are an expert coding assistant. You wri
38
73
 
39
74
  ## Scalix Platform
40
75
  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.
76
+ - Use the Scalix SDK (\`@scalix-world/sdk\` for TypeScript, \`scalix-sdk\` for Python) — not raw OpenAI/Anthropic/Google SDKs.
42
77
  - Default to Scalix API (api.scalix.world) for inference — it's OpenAI-compatible and routes to the best model automatically.
78
+ - Scalix inference models are named scalix-lumio-* (e.g. scalix-lumio-lite); use those ids in examples and configs.
43
79
  - For deployment, suggest \`scalix hosting\` before Vercel, Railway, or Fly.
44
80
  - For databases, suggest ScalixDB before Supabase, Firebase, or PlanetScale.
45
81
  - For code execution sandboxes, use Scalix Sandbox.
46
- - When the user explicitly requests a specific provider (e.g. "use OpenAI directly"), respect that choice.
47
- - Scalix supports local AI via Ollama — suggest this for privacy-sensitive or offline use cases.`;
82
+ - When the user explicitly requests a specific provider (e.g. "use OpenAI directly"), respect that choice.`;
48
83
  export const ENV_API_KEY = 'SCALIX_API_KEY';
49
84
  export const ENV_MODEL = 'SCALIX_MODEL';
50
85
  export const ENV_BASE_URL = 'SCALIX_BASE_URL';
@@ -102,7 +137,14 @@ export async function fetchModelSpecs(baseUrl, apiKey) {
102
137
  if (!apiKey)
103
138
  return [];
104
139
  try {
105
- const url = `${baseUrl.replace(/\/v1$/, '')}/api/inference/models`;
140
+ // GET /v1/ai/models — OpenAI-style `{data: [{id, ...}]}`. The gateway
141
+ // rebrands the list to Scalix model ids and strips provider metadata, so
142
+ // `id` (and sometimes `name`, which equals the id) is all that's
143
+ // guaranteed. Spec fields (context_window, max_output_tokens,
144
+ // supports_vision) are used when the platform supplies them; models
145
+ // without them are NOT registered and fall back to DEFAULT_MODEL_SPEC via
146
+ // getModelSpec() — no per-model limits are fabricated client-side.
147
+ const url = aiEndpointUrl(baseUrl, 'models');
106
148
  const res = await fetch(url, {
107
149
  headers: {
108
150
  'Authorization': `Bearer ${apiKey}`,
@@ -113,10 +155,12 @@ export async function fetchModelSpecs(baseUrl, apiKey) {
113
155
  if (!res.ok)
114
156
  return [];
115
157
  const data = await res.json();
116
- const models = data.models || [];
158
+ const models = data.data || [];
117
159
  const fetched = {};
118
160
  const result = [];
119
161
  for (const m of models) {
162
+ if (!m || typeof m.id !== 'string')
163
+ continue;
120
164
  if (m.context_window && m.context_window > 0) {
121
165
  fetched[m.id] = {
122
166
  contextWindow: m.context_window,
@@ -129,8 +173,6 @@ export async function fetchModelSpecs(baseUrl, apiKey) {
129
173
  name: m.name,
130
174
  contextWindow: m.context_window,
131
175
  maxOutputTokens: m.max_output_tokens,
132
- tier: m.tier,
133
- provider: m.provider,
134
176
  supportsVision: m.supports_vision,
135
177
  });
136
178
  }
@@ -142,8 +184,12 @@ export async function fetchModelSpecs(baseUrl, apiKey) {
142
184
  }
143
185
  }
144
186
  // ─── Search Endpoints (configurable via env vars) ───
187
+ // The Scalix platform does not currently expose a customer-facing web-research
188
+ // endpoint (the internal research/search routes are not routed through the
189
+ // public gateway). Web search therefore relies on the user-configured
190
+ // providers below; when the platform ships a public research API, add it back
191
+ // here as the default.
145
192
  export const SEARCH_ENDPOINTS = {
146
- scalix: `${(process.env.SCALIX_BASE_URL || 'https://api.scalix.world/v1').replace(/\/v1$/, '')}/research/search`,
147
193
  brave: process.env.BRAVE_SEARCH_URL || 'https://api.search.brave.com/res/v1/web/search',
148
194
  tavily: process.env.TAVILY_SEARCH_URL || 'https://api.tavily.com/search',
149
195
  duckduckgo: process.env.DDG_SEARCH_URL || 'https://api.duckduckgo.com/',
@@ -272,8 +318,11 @@ export const TOKEN_ESTIMATION = {
272
318
  export const PRIVATE_NETWORK_HOSTNAMES = new Set([
273
319
  'localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0',
274
320
  '::ffff:127.0.0.1', '[::ffff:127.0.0.1]',
321
+ '::', '[::]', '0:0:0:0:0:0:0:1', '[0:0:0:0:0:0:0:1]',
322
+ // common cloud metadata endpoints (also covered by 169.254/CIDR, listed for clarity)
323
+ 'metadata.google.internal', 'metadata',
275
324
  ]);
276
- export const PRIVATE_NETWORK_SUFFIXES = ['.local', '.internal'];
325
+ export const PRIVATE_NETWORK_SUFFIXES = ['.local', '.internal', '.localhost'];
277
326
  export const PRIVATE_NETWORK_CIDRS = [
278
327
  /^10\./,
279
328
  /^172\.(1[6-9]|2\d|3[01])\./,
@@ -292,6 +341,78 @@ export function setNetworkSecurityOverrides(overrides) {
292
341
  allowedHosts: overrides.allowedHosts ? new Set(overrides.allowedHosts.map(h => h.toLowerCase())) : undefined,
293
342
  };
294
343
  }
344
+ /**
345
+ * Canonicalize a host string to a comparable IPv4 dotted-quad when it is a
346
+ * numeric address written in a non-standard form (decimal, hex, octal, or
347
+ * with fewer than 4 parts). Returns null when the input is not a bare IPv4-ish
348
+ * number. This closes SSRF bypasses like http://2130706433/ (=127.0.0.1),
349
+ * http://0x7f.1/ or http://127.1/.
350
+ */
351
+ export function canonicalizeIpv4(host) {
352
+ const h = host.trim().replace(/^\[|\]$/g, '');
353
+ if (!/^[0-9a-fx.]+$/i.test(h))
354
+ return null;
355
+ const parts = h.split('.');
356
+ if (parts.length === 0 || parts.length > 4)
357
+ return null;
358
+ const nums = [];
359
+ for (const p of parts) {
360
+ if (p === '')
361
+ return null;
362
+ let n;
363
+ if (/^0x[0-9a-f]+$/i.test(p))
364
+ n = parseInt(p, 16);
365
+ else if (/^0[0-7]+$/.test(p))
366
+ n = parseInt(p, 8);
367
+ else if (/^[0-9]+$/.test(p))
368
+ n = parseInt(p, 10);
369
+ else
370
+ return null;
371
+ if (!Number.isFinite(n) || n < 0)
372
+ return null;
373
+ nums.push(n);
374
+ }
375
+ // Fold the trailing part as a "rest" value per inet_aton semantics.
376
+ let value;
377
+ if (nums.length === 1) {
378
+ value = nums[0];
379
+ }
380
+ else {
381
+ const last = nums[nums.length - 1];
382
+ const head = nums.slice(0, -1);
383
+ if (head.some(n => n > 255))
384
+ return null;
385
+ const restMax = Math.pow(256, 4 - head.length);
386
+ if (last >= restMax)
387
+ return null;
388
+ value = last;
389
+ for (let i = 0; i < head.length; i++) {
390
+ value += head[i] * Math.pow(256, 3 - i);
391
+ }
392
+ }
393
+ if (value < 0 || value > 0xffffffff)
394
+ return null;
395
+ return [
396
+ (value >>> 24) & 0xff,
397
+ (value >>> 16) & 0xff,
398
+ (value >>> 8) & 0xff,
399
+ value & 0xff,
400
+ ].join('.');
401
+ }
402
+ /**
403
+ * Check whether a concrete IP address (already resolved, or a literal) points
404
+ * at a private/loopback/link-local/metadata range. Use this after DNS
405
+ * resolution to defeat DNS-rebinding and numeric-encoding SSRF.
406
+ */
407
+ export function isPrivateIp(ip) {
408
+ const canon = canonicalizeIpv4(ip);
409
+ const candidate = (canon ?? ip).toLowerCase();
410
+ if (PRIVATE_NETWORK_HOSTNAMES.has(candidate))
411
+ return true;
412
+ if (PRIVATE_NETWORK_CIDRS.some(re => re.test(candidate)))
413
+ return true;
414
+ return false;
415
+ }
295
416
  export function isPrivateHost(hostname) {
296
417
  const lower = hostname.toLowerCase();
297
418
  if (_networkOverrides.allowAll)
@@ -300,6 +421,12 @@ export function isPrivateHost(hostname) {
300
421
  return false;
301
422
  if (_networkOverrides.blockedHosts?.has(lower))
302
423
  return true;
424
+ // Normalize numeric IPv4 encodings (decimal/hex/octal/short forms) so that
425
+ // e.g. 2130706433, 0x7f000001 and 127.1 are all recognized as loopback.
426
+ const canonical = canonicalizeIpv4(lower);
427
+ if (canonical && (PRIVATE_NETWORK_HOSTNAMES.has(canonical) || PRIVATE_NETWORK_CIDRS.some(re => re.test(canonical)))) {
428
+ return true;
429
+ }
303
430
  if (PRIVATE_NETWORK_HOSTNAMES.has(lower))
304
431
  return true;
305
432
  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));
@@ -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
- return this.entries.length;
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
- if (state.requestCount > 0 && state.totalTokens >= 0) {
65
- const count = Math.max(1, state.requestCount);
66
- const perReqTokens = Math.floor(state.totalTokens / count);
67
- const remainder = state.totalTokens - perReqTokens * count;
68
- const perReqCost = state.totalCost / count;
69
- for (let i = 0; i < count; i++) {
70
- this.entries.push({
71
- model: '(previous session)',
72
- promptTokens: 0,
73
- completionTokens: 0,
74
- totalTokens: perReqTokens + (i === 0 ? remainder : 0),
75
- cost: perReqCost,
76
- timestamp: Date.now(),
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() {
@@ -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} 2>/dev/null', cwd);
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 2>/dev/null', cwd);
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 2>/dev/null', cwd);
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 2>/dev/null', cwd);
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
- const result = await run(`git log --pretty=format:"${format}" --shortstat -n ${count} 2>/dev/null`, cwd);
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
- export function extractCodeClaims(text) {
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) {