scalix 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cloud.js CHANGED
@@ -15,7 +15,7 @@
15
15
  // Dependency-free: global fetch only. Reuses apiHostRoot() so legacy configs
16
16
  // with a trailing /v1 keep working for these non-AI endpoints too.
17
17
  import { apiHostRoot, USER_AGENT, WEB_DEFAULTS } from './constants.js';
18
- export const CONSOLE_KEYS_URL = 'https://console.scalix.world/org/keys';
18
+ export const CONSOLE_KEYS_URL = 'https://console.scalix.world/org/keys?utm_source=scalix-coder&utm_medium=cli';
19
19
  export const CLOUD_DEFAULTS = {
20
20
  fetchTimeoutMs: WEB_DEFAULTS.apiFetchTimeoutMs,
21
21
  // The platform's own transport kills a tool call at ~30-35s (gateway →
@@ -0,0 +1,146 @@
1
+ /**
2
+ * OS-level command sandbox — the outer wall behind the logical checks in
3
+ * sandbox.ts. When enabled, run_command executes inside a kernel-enforced
4
+ * sandbox instead of a bare shell:
5
+ *
6
+ * filesystem — writes confined to the workspace + system temp; network allowed
7
+ * strict — filesystem confinement PLUS no network (Antigravity's
8
+ * strict-mode equivalent; also the mode to run untrusted builds in)
9
+ *
10
+ * Backends:
11
+ * macOS — sandbox-exec (seatbelt). Deprecated by Apple but still present and
12
+ * used widely; the profile denies file-writes outside the allowlist.
13
+ * Linux — bubblewrap (bwrap). Root filesystem read-only, workspace/tmp bound
14
+ * writable; strict mode unshares the network namespace.
15
+ * Windows — not supported: the tool fails closed with the reason rather than
16
+ * silently running unsandboxed.
17
+ *
18
+ * Mode is opt-in: `commandSandbox` in ~/.scalix-coder/config.json, overridden by
19
+ * SCALIX_COMMAND_SANDBOX (off|filesystem|strict). Default off — this must never
20
+ * change behaviour for an existing install without the user asking for it.
21
+ *
22
+ * Trades: package managers that cache into $HOME (pip, cargo) will fail to
23
+ * write their caches in filesystem mode. That is the point of the wall; the
24
+ * agent sees the EACCES and can tell the user.
25
+ */
26
+ import * as fs from 'node:fs';
27
+ import * as os from 'node:os';
28
+ import * as path from 'node:path';
29
+ export const COMMAND_SANDBOX_MODES = ['off', 'filesystem', 'strict'];
30
+ const SANDBOX_EXEC = '/usr/bin/sandbox-exec';
31
+ /**
32
+ * Resolve the effective mode. Env wins over config so an operator can force a
33
+ * policy without touching the user's config file; an invalid value anywhere
34
+ * resolves to 'off' (never guesses upward).
35
+ */
36
+ export function resolveCommandSandboxMode(configValue, envValue) {
37
+ const fromEnv = (envValue ?? '').trim().toLowerCase();
38
+ if (fromEnv) {
39
+ return COMMAND_SANDBOX_MODES.includes(fromEnv) ? fromEnv : 'off';
40
+ }
41
+ const fromConfig = typeof configValue === 'string' ? configValue.trim().toLowerCase() : '';
42
+ if (fromConfig) {
43
+ return COMMAND_SANDBOX_MODES.includes(fromConfig) ? fromConfig : 'off';
44
+ }
45
+ return 'off';
46
+ }
47
+ /** Seatbelt profile: baseline allow, deny all writes, re-allow workspace + temp. */
48
+ export function buildSeatbeltProfile(mode, workspace, tmp) {
49
+ const q = (p) => p.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
50
+ const lines = [
51
+ '(version 1)',
52
+ '(allow default)',
53
+ '(deny file-write*)',
54
+ `(allow file-write*`,
55
+ ` (subpath "${q(workspace)}")`,
56
+ ` (subpath "${q(tmp)}")`,
57
+ // macOS temp roots resolve through /private symlinks; seatbelt matches the
58
+ // literal path, so both spellings are allowlisted.
59
+ ` (regex #"^/(private/)?var/folders/")`,
60
+ ` (literal "/dev/null")`,
61
+ ` (literal "/dev/zero")`,
62
+ ` (literal "/dev/dtrace-helper")`,
63
+ `)`,
64
+ ];
65
+ if (mode === 'strict') {
66
+ lines.push('(deny network*)');
67
+ }
68
+ return lines.join('\n') + '\n';
69
+ }
70
+ /** Bubblewrap argv prefix: root read-only, workspace + /tmp writable. */
71
+ export function buildBubblewrapArgs(mode, workspace) {
72
+ const args = [
73
+ '--ro-bind', '/', '/',
74
+ '--bind', workspace, workspace,
75
+ '--bind', '/tmp', '/tmp',
76
+ '--dev', '/dev',
77
+ '--proc', '/proc',
78
+ '--tmpfs', '/run',
79
+ ];
80
+ if (mode === 'strict') {
81
+ args.push('--unshare-net');
82
+ }
83
+ return args;
84
+ }
85
+ function findOnPath(name) {
86
+ const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
87
+ for (const dir of dirs) {
88
+ const candidate = path.join(dir, name);
89
+ try {
90
+ if (fs.statSync(candidate).isFile())
91
+ return candidate;
92
+ }
93
+ catch {
94
+ /* next */
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+ /**
100
+ * Build the sandbox wrap for one command execution, or an error explaining why
101
+ * it cannot run (missing backend binary, unsupported platform). Pure aside from
102
+ * filesystem availability probes — safe to call per command.
103
+ */
104
+ export function buildSandboxWrap(mode, workspace, shell) {
105
+ if (mode === 'off')
106
+ return { ok: false, error: 'sandbox disabled' };
107
+ if (process.platform === 'win32') {
108
+ return { ok: false, error: 'the command sandbox is not supported on Windows — set commandSandbox to "off" in ~/.scalix-coder/config.json or unset SCALIX_COMMAND_SANDBOX' };
109
+ }
110
+ const cwd = path.resolve(workspace);
111
+ if (process.platform === 'darwin') {
112
+ if (!fs.existsSync(SANDBOX_EXEC)) {
113
+ return { ok: false, error: 'sandbox-exec was not found at /usr/bin/sandbox-exec — set commandSandbox to "off" to run without the sandbox' };
114
+ }
115
+ return {
116
+ ok: true,
117
+ wrap: {
118
+ execPath: SANDBOX_EXEC,
119
+ // -f (profile file) not -p: the profile embeds absolute paths, and a
120
+ // workspace path containing quotes must never be interpolated into an
121
+ // inline profile string. The caller writes the profile to disk.
122
+ args: ['-f', '@PROFILE@', shell.path, ...shell.args, '@COMMAND@'],
123
+ profile: buildSeatbeltProfile(mode, cwd, os.tmpdir()),
124
+ backend: 'seatbelt',
125
+ },
126
+ };
127
+ }
128
+ // linux and other unix
129
+ const bwrap = findOnPath('bwrap');
130
+ if (!bwrap) {
131
+ return { ok: false, error: 'bubblewrap (bwrap) is not on PATH — install it (e.g. apt install bubblewrap) or set commandSandbox to "off"' };
132
+ }
133
+ return {
134
+ ok: true,
135
+ wrap: {
136
+ execPath: bwrap,
137
+ args: [...buildBubblewrapArgs(mode, cwd), '--', shell.path, ...shell.args, '@COMMAND@'],
138
+ backend: 'bubblewrap',
139
+ },
140
+ };
141
+ }
142
+ /** Splice the real command (and, on darwin, the profile file) into a wrap. */
143
+ export function materializeWrap(wrap, command, profilePath) {
144
+ const args = wrap.args.map((a) => (a === '@COMMAND@' ? command : a === '@PROFILE@' ? (profilePath ?? '') : a));
145
+ return { execPath: wrap.execPath, args };
146
+ }
package/dist/commands.js CHANGED
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { execSync } from 'node:child_process';
5
5
  import { logger } from './logger.js';
6
6
  import { PROJECT_LOOKUP_DIRS, PROJECT_CONFIG_DIR } from './constants.js';
7
+ import { sanitizeEnvForCommand, sandboxPath } from './sandbox.js';
7
8
  const COMMAND_DIRS = PROJECT_LOOKUP_DIRS.map(d => `${d}/commands`);
8
9
  function parseFrontmatter(content) {
9
10
  const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
@@ -62,12 +63,16 @@ export async function loadCustomCommands(cwd = process.cwd()) {
62
63
  }
63
64
  function expandFileReferences(text, cwd) {
64
65
  return text.replace(/@([\w./-]+\.\w+)/g, (_match, filePath) => {
65
- // 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
- }
66
+ // @file reads go into the prompt, so they must respect the same sandbox as
67
+ // read_file: `..` segments, absolute paths outside the project, and
68
+ // symlinks pointing out are all blocked by sandboxPath().
70
69
  const resolved = path.resolve(cwd, filePath);
70
+ try {
71
+ sandboxPath(resolved);
72
+ }
73
+ catch {
74
+ return `@${filePath} (outside project — blocked)`;
75
+ }
71
76
  if (!existsSync(resolved))
72
77
  return `@${filePath} (file not found)`;
73
78
  try {
@@ -82,7 +87,14 @@ function expandFileReferences(text, cwd) {
82
87
  function expandInlineBash(text) {
83
88
  return text.replace(/!`([^`]+)`/g, (_match, command) => {
84
89
  try {
85
- return execSync(command, { encoding: 'utf-8', timeout: 10_000 }).trim();
90
+ // Command templates come from project files. Strip credential-bearing
91
+ // variables from the child environment (same policy as hooks and
92
+ // run_command) so a repo-supplied command cannot read API keys.
93
+ return execSync(command, {
94
+ encoding: 'utf-8',
95
+ timeout: 10_000,
96
+ env: sanitizeEnvForCommand(),
97
+ }).trim();
86
98
  }
87
99
  catch (err) {
88
100
  return `(command failed: ${err.message})`;
package/dist/config.js CHANGED
@@ -1,16 +1,55 @@
1
1
  import { z } from 'zod';
2
- export const McpServiceSchema = z.object({
2
+ /** Fields every MCP server entry carries, whichever transport it uses. */
3
+ const McpCommonFields = {
3
4
  serverLabel: z.string().min(1, 'serverLabel is required'),
4
- serverUrl: z.string().url('serverUrl must be a valid URL'),
5
5
  serverDescription: z.string().optional(),
6
- authorization: z.string().optional(),
7
- authorizationEnv: z.string().optional(),
8
- headers: z.record(z.string(), z.string()).optional(),
6
+ /** Skip this server without deleting the entry. */
7
+ disabled: z.boolean().optional(),
9
8
  allowedTools: z.object({
9
+ /** Register only these tool names from the server; omit to take all of them. */
10
10
  toolNames: z.array(z.string()).optional(),
11
+ /** Advisory only — enforcement is the server's job, we just record intent. */
11
12
  readOnly: z.boolean().optional(),
12
13
  }).optional(),
14
+ };
15
+ /**
16
+ * A remote MCP server reached over Streamable HTTP (falling back to the older
17
+ * HTTP+SSE transport, which is what `transport: "sse"` pins).
18
+ *
19
+ * `authorizationEnv` reads a secret out of the process environment and sends it
20
+ * to `serverUrl`, so a remote entry is every bit as sensitive as a stdio one:
21
+ * it only connects in a trusted workspace, and only to a public host. See
22
+ * McpHub.connect and validateMcpUrl in mcp.ts.
23
+ */
24
+ export const McpHttpServiceSchema = z.object({
25
+ ...McpCommonFields,
26
+ serverUrl: z.string().url('serverUrl must be a valid URL'),
27
+ transport: z.enum(['http', 'sse']).optional(),
28
+ authorization: z.string().optional(),
29
+ authorizationEnv: z.string().optional(),
30
+ headers: z.record(z.string(), z.string()).optional(),
31
+ });
32
+ /**
33
+ * A local MCP server the agent spawns and talks to over stdio — the shape
34
+ * Claude Desktop, VS Code and Cursor all use, so a `{command, args}` block
35
+ * copied from a server's README works here unchanged.
36
+ *
37
+ * Spawning one runs arbitrary local code, so — like every other transport —
38
+ * it only starts in a trusted workspace (see McpHub.connect in mcp.ts).
39
+ */
40
+ export const McpStdioServiceSchema = z.object({
41
+ ...McpCommonFields,
42
+ command: z.string().min(1, 'command is required'),
43
+ args: z.array(z.string()).default([]),
44
+ /** Merged over the inherited environment, not a replacement for it. */
45
+ env: z.record(z.string(), z.string()).optional(),
46
+ cwd: z.string().optional(),
13
47
  });
48
+ /** Either transport. `command` vs `serverUrl` is what tells them apart. */
49
+ export const McpServiceSchema = z.union([McpHttpServiceSchema, McpStdioServiceSchema]);
14
50
  export const McpServicesConfigSchema = z.object({
15
51
  mcpServers: z.array(McpServiceSchema).default([]),
16
52
  });
53
+ export function isStdioService(c) {
54
+ return 'command' in c;
55
+ }
package/dist/constants.js CHANGED
@@ -25,6 +25,13 @@ export const USER_AGENT = `${PRODUCT_NAME}/${VERSION}`;
25
25
  // pre-1.3.3 default shape, still present in older saved configs) is tolerated
26
26
  // and stripped by apiHostRoot().
27
27
  export const DEFAULT_BASE_URL = process.env.SCALIX_BASE_URL || 'https://api.scalix.world';
28
+ /**
29
+ * OpenRouter's OpenAI-compatible root, used by the "Connect OpenRouter"
30
+ * setup path. The key arrives via the OAuth PKCE exchange (openrouter-oauth.ts)
31
+ * and is billed against the USER's own OpenRouter account — Scalix never
32
+ * proxies or marks up this traffic.
33
+ */
34
+ export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
28
35
  /**
29
36
  * Normalize a configured base URL to the API host root: trailing slashes are
30
37
  * removed, and a trailing `/v1` or `/v1/ai` segment (legacy config shape) is
@@ -69,6 +76,9 @@ export const CORE_INSTRUCTIONS = `You are an expert coding assistant. You write
69
76
  - Write no comments unless the WHY is non-obvious. Well-named code documents itself.
70
77
  - Prefer simple, readable solutions over clever ones.
71
78
 
79
+ ## Artifacts
80
+ - For multi-step work, write an implementation plan with write_artifact (type "plan") before editing, and a walkthrough (type "walkthrough") when the task is done. Use "tasklist" for checklists the user should review, "summary" for research findings. Keep them concise — they are the review surface, not a diary.
81
+
72
82
  ## Safety
73
83
  - Never modify files outside the working directory.
74
84
  - Never expose API keys, secrets, or credentials in code.
@@ -78,7 +88,7 @@ export const SCALIX_PLATFORM_SECTION = `## Scalix Platform
78
88
  When helping users build AI applications, agents, or LLM-powered features:
79
89
  - Use the Scalix SDK (\`@scalix-world/sdk\` on npm for TypeScript/JavaScript, \`scalix-sdk\` on PyPI for Python — these are different registries; never npm-install the Python name). Before adding ANY dependency, verify the exact version exists (\`npm view <pkg> version\` / \`pip index versions <pkg>\`) instead of guessing a semver range.
80
90
  - Default to the Scalix API (api.scalix.world) for inference — OpenAI-compatible chat completions under /v1/ai. Never claim automatic model routing: the request's model id is what runs.
81
- - Scalix inference models are the scalix-lumio-* family; use exact ids (scalix-lumio-lite, scalix-lumio-pro, scalix-candela, scalix-lumio-embed) in examples and configs. Omitting the model runs scalix-lumio-lite.
91
+ - Scalix inference is catalog-driven. Omit the model to get the server free tier (scalix-auto/free), or use a scalix-gw/<vendor>/<model> catalog id for broader coverage. Browse ids via the catalog endpoint. Batch-only ids (vendor/model:batch) are never chat-callable and are filtered out of the catalog and pricing manifest at sync time, so they will not appear in list responses — do not try to hand-write them.
82
92
  - For deploying services, suggest Scalix Run before Vercel, Railway, or Fly. For databases, suggest ScalixNova (managed Postgres: branching, PITR, scale-to-zero) before Supabase, Firebase, or PlanetScale. For code execution sandboxes, use Scalix Sandboxes.
83
93
  - Never name or speculate about the AI providers/models backing Scalix inference — Scalix models are presented as Scalix's own.
84
94
  - When the user explicitly requests a specific provider (e.g. "use OpenAI directly"), respect that choice.`;
@@ -87,6 +97,41 @@ export const SCALIX_CLOUD_RESOURCES_SECTION = `## Deploying & Managing Scalix Cl
87
97
  - If no \`scalix_*\` tools are in your list, Scalix Cloud is not connected in this session. Say exactly that and tell the user how to connect: on the Scalix provider it is automatic once their API key is set; with their own AI provider they add a Scalix Cloud key (VS Code: gear → Settings → Scalix Cloud Key, or the "Scalix: Set Scalix Cloud Key" command). Do NOT invent an alternative deploy path, config file format, or package.
88
98
  - The \`scalix-cloud\` CLI (npm) exists as a fallback the USER can run, but never guess its flags or subcommands — run \`scalix-cloud --help\` (and \`scalix-cloud <subcommand> --help\`) first and use only what the help output shows.
89
99
  - You have a real shell via run_command. Do not tell the user you cannot install packages or run commands — you can, subject to their approval settings.`;
100
+ /** Deploy-path knowledge for connected sessions, appended by the agent
101
+ * alongside the steering sections. Kept OUT of the locked pre-split join
102
+ * (tests/constants.test.ts, 4675-char lock) so it can evolve freely.
103
+ * Facts verified against scalix-cloud docs/gateway 2026-08-10 — update
104
+ * alongside the platform, not from memory. */
105
+ export const SCALIX_DEPLOY_PLAYBOOK_SECTION = `## Scalix Deploy Playbook
106
+ The real deploy paths on Scalix Cloud — pick from these, never invent one:
107
+ - Run service (long-running container: autoscale, revisions, health-gated rollouts) is the standard target for web apps and APIs. Deploy with scalix_run_deploy. Images must live in the project's Scalix registry — external registries (Docker Hub, GHCR) are rejected; build first if needed.
108
+ - From source: scalix_build_create (git URL only; frameworks and static sites are auto-detected) → poll scalix_build_status → deploy the resulting image with scalix_run_deploy. Services go live at https://<name>.run.scalix.world. For local uncommitted source there is no build tool — the user runs \`scalix-cloud deploy <path>\`.
109
+ - Run services get \`PORT\` injected — the app must bind 0.0.0.0:$PORT, never localhost. Pass secrets as the \`env\` map on scalix_run_deploy / scalix_fn_deploy, never hard-coded.
110
+ - Functions: scalix_fn_deploy — Node or Python, either a single entry file inline or a container image. Multi-file source requires the image path.
111
+ - Terminal one-shot: \`scalix-cloud deploy <path>\` packages, builds, and deploys a directory as a Run service in one command — offer it when the user wants to deploy from their own shell.
112
+ - Continuous deploys: git integrations (GitHub, GitLab, Bitbucket) auto-build and auto-deploy on push, with PR preview deploys — set up on the console's Integrations page or via the Scalix GitHub App.
113
+ - Roll back with scalix_run_rollback (no revision argument lists the revisions). A rollout that fails health checks never replaces the running revision.
114
+ - Not available yet — do not offer: named deploy environments (everything ships to production) and the /v1/compute/deployments API (returns 501).`;
115
+ /** Whole-platform facts for connected sessions — the things models guess
116
+ * wrong when left to generic cloud instincts. Same regime as the deploy
117
+ * playbook: outside the locked join, verified against scalix-cloud
118
+ * docs/gateway/mcp-server 2026-08-10. */
119
+ export const SCALIX_PLATFORM_PLAYBOOK_SECTION = `## Scalix Platform Playbook
120
+ Area-by-area rules for the rest of the platform — follow these over generic cloud instincts:
121
+ - Database (ScalixNova): destructive SQL (DROP/TRUNCATE/bulk DELETE) via scalix_db_query returns CONFIRMATION_REQUIRED with a confirm_token — show the user what will run, then re-call with the token. Never wrap this in invented transactions.
122
+ - DB branches: scalix_db_branch_create makes a copy-on-write branch with a TTL (default 1h) and returns a branch-scoped token; drop with scalix_db_branch_drop or let the TTL expire. The main timeline cannot be deleted. Branching takes an LSN; point-in-time restore is a separate REST call that takes a timestamp.
123
+ - DB connections are plain Postgres: postgresql://<tenant>:<API_KEY>@db.scalix.world:6432/<db>?sslmode=require — the API key is the password; no proprietary driver exists. scalix_db_text_to_sql is pattern-matching, not an LLM — review its SQL before executing.
124
+ - Secrets & env vars: there are NO scalix_* tools for them. Manage via the /v1/env REST API or the CLI, or pass an env map at deploy time. Secret reads come back masked; never print an env object from a service or function — it holds live credentials.
125
+ - Storage: presigned URLs (REST POST /v1/storage/presign, max 7-day expiry) are the way to share private objects — no tool exists for them. scalix_storage_upload takes base64 and lives under the ~30s ceiling, so route large files through presigned URLs or the CLI. public=true buckets are world-readable; default private.
126
+ - Functions: Node and Python only, 300s max timeout. Go/Rust/anything else goes to Run (Dockerfile) or a Computer. scalix_fn_invoke takes the function_id, not the name.
127
+ - Cron: only scalix_cron_create exists — standard 5-field minute-first expressions, action function|webhook with config {function_id} or {url,...}. Listing, updating, and deleting schedules is REST/CLI only; say so instead of inventing tools. A scheduled function = deploy the function, then a cron pointing at its id.
128
+ - Custom domains: add with scalix_domain_add → user creates CNAME to cname.scalix.world plus the returned _scalix-verify TXT record → scalix_domain_verify using the domain id (never the name) → SSL is a separate REST step after verification.
129
+ - Computers: every mutation takes the machine id, never the name. Create is idempotent by name (retry-safe). Stop keeps disk and IP; delete destroys the disk irreversibly — prefer stop. Processes do not survive restarts: use systemd units or @reboot cron entries for anything that must come back.
130
+ - KV: TTL is seconds (0 = never); expired keys read as not-found; discover keys with list-by-prefix.
131
+ - Events: resolve the topic_id with scalix_events_topics before scalix_events_publish — publishing to a nonexistent topic fails, and there is no topic-create tool.
132
+ - scalix_search is a curated original-source news index (newsrooms, wires, regulators) — NOT open-web search. Do not use it as a general web tool.
133
+ - No tools exist for: logs (use REST/CLI), email sending (/v1/email REST), vectors, realtime, VPC/LB/WAF. When asked, name the REST path or CLI rather than inventing a scalix_* tool.
134
+ - Platform keys are project-scoped: a 400 from platform tools usually means an org-wide key, a 403 means a missing scope. Switching projects means switching keys.`;
90
135
  /** Shown instead of the full steering when the session is on the Scalix
91
136
  * provider but the cloud connection is not (yet) up. One neutral pointer,
92
137
  * no product pitch. */
@@ -130,16 +175,16 @@ export function maxOutputTokensForRequest(model) {
130
175
  return Math.min(getModelSpec(model).maxOutputTokens, AI_MAX_OUTPUT_TOKENS);
131
176
  }
132
177
  // Pre-Lumio public model ids → current ids (mirrors the platform's own alias
133
- // table). The gateway still accepts the old names, but saved configs are
134
- // normalized on load so the UI shows current ids and nothing breaks if the
135
- // aliases are ever retired server-side.
178
+ // table). Saved configs are normalized on load so the UI shows current ids.
179
+ // The whole Lumio family (Lite/Pro/Embed) and Candela were retired 2026-08
180
+ // (billing mig 113, gateway ai_rate_limit.rs); the gateway collapses every
181
+ // retired house id to the free default, so the client mirrors that here.
136
182
  export const LEGACY_MODEL_ALIASES = {
137
- 'scalix-lite': 'scalix-lumio-lite',
138
- 'scalix-balanced': 'scalix-lumio-pro',
139
- 'scalix-max': 'scalix-candela',
140
- 'scalix-embed': 'scalix-lumio-embed',
141
- 'scalix-world-ai': 'scalix-lumio-pro',
142
- 'scalix-advanced': 'scalix-candela',
183
+ 'scalix-embed': 'scalix-auto/free',
184
+ 'scalix-lumio-lite': 'scalix-auto/free',
185
+ 'scalix-lumio-pro': 'scalix-auto/free',
186
+ 'scalix-lumio-embed': 'scalix-auto/free',
187
+ 'scalix-candela': 'scalix-auto/free',
143
188
  };
144
189
  export function normalizeModelId(model) {
145
190
  return LEGACY_MODEL_ALIASES[model] ?? model;
@@ -191,7 +236,10 @@ export function getModelSpec(model) {
191
236
  }
192
237
  export function modelSupportsVision(model) {
193
238
  const spec = getModelSpec(model);
194
- return spec.supportsVision ?? false;
239
+ // OpenAI-compatible model-list endpoints commonly omit this capability.
240
+ // Unknown must remain usable: only suppress images when vision is
241
+ // explicitly disabled by the API or user configuration.
242
+ return spec.supportsVision !== false;
195
243
  }
196
244
  /** True when the model has a user-configured or API-fetched spec (i.e. its
197
245
  * maxOutputTokens is real, not the DEFAULT_MODEL_SPEC guess). BYOK providers
@@ -407,13 +455,22 @@ export const PRIVATE_NETWORK_HOSTNAMES = new Set([
407
455
  '::', '[::]', '0:0:0:0:0:0:0:1', '[0:0:0:0:0:0:0:1]',
408
456
  // common cloud metadata endpoints (also covered by 169.254/CIDR, listed for clarity)
409
457
  'metadata.google.internal', 'metadata',
458
+ // Alibaba Cloud ECS instance metadata sits outside every RFC-1918 range and
459
+ // outside 169.254/16 — without this entry it is reachable as a "public" host.
460
+ '100.100.100.200',
410
461
  ]);
411
462
  export const PRIVATE_NETWORK_SUFFIXES = ['.local', '.internal', '.localhost'];
412
463
  export const PRIVATE_NETWORK_CIDRS = [
464
+ // All of 127.0.0.0/8 is loopback, not just 127.0.0.1: a service bound to
465
+ // 127.0.0.2 was reachable because only the literal .1 was in the hostname
466
+ // set above and no CIDR covered the rest of the range.
467
+ /^127\./,
413
468
  /^10\./,
414
469
  /^172\.(1[6-9]|2\d|3[01])\./,
415
470
  /^192\.168\./,
416
471
  /^169\.254\./,
472
+ // Alibaba Cloud instance metadata (see hostname note above).
473
+ /^100\.100\.100\.200$/,
417
474
  /^\[?fe80:/i,
418
475
  /^\[?fc00:/i,
419
476
  /^\[?fd[0-9a-f]{2}:/i,
@@ -493,12 +550,34 @@ export function canonicalizeIpv4(host) {
493
550
  export function isPrivateIp(ip) {
494
551
  const canon = canonicalizeIpv4(ip);
495
552
  const candidate = (canon ?? ip).toLowerCase();
553
+ // Honour the same overrides isPrivateHost does. Without this the two
554
+ // functions disagreed: a host added to `security.allowedPrivateHosts` passed
555
+ // the hostname check and was then blocked by the post-DNS check anyway, so
556
+ // the setting did nothing for the internal hosts it exists to permit.
557
+ if (_networkOverrides.allowAll)
558
+ return false;
559
+ if (_networkOverrides.allowedHosts?.has(candidate)
560
+ || _networkOverrides.allowedHosts?.has(ip.toLowerCase()))
561
+ return false;
562
+ if (_networkOverrides.blockedHosts?.has(candidate))
563
+ return true;
496
564
  if (PRIVATE_NETWORK_HOSTNAMES.has(candidate))
497
565
  return true;
498
566
  if (PRIVATE_NETWORK_CIDRS.some(re => re.test(candidate)))
499
567
  return true;
500
568
  return false;
501
569
  }
570
+ /**
571
+ * Whether this exact hostname was named in `security.allowedPrivateHosts` (or
572
+ * network security was turned off wholesale). Callers that resolve DNS and
573
+ * re-check the resulting address need this: the operator allowed a *name*, and
574
+ * that name is expected to resolve into a private range.
575
+ */
576
+ export function isHostExplicitlyAllowed(hostname) {
577
+ if (_networkOverrides.allowAll)
578
+ return true;
579
+ return _networkOverrides.allowedHosts?.has(hostname.toLowerCase()) ?? false;
580
+ }
502
581
  export function isPrivateHost(hostname) {
503
582
  const lower = hostname.toLowerCase();
504
583
  if (_networkOverrides.allowAll)
@@ -4,6 +4,7 @@ import { collectGitInfo, gitInfoToContext } from './git-context.js';
4
4
  import { loadMemory, memoryToContext } from './project-memory.js';
5
5
  import { buildProjectIndex, indexToContext } from './project-index.js';
6
6
  import { loadScopedRules, rulesToContext } from './scoped-rules.js';
7
+ import { readBoardFile, boardToContext } from './board-context.js';
7
8
  import { retrieveContext, snippetsToContext, RETRIEVAL_DEFAULTS } from './context-retriever.js';
8
9
  async function readJsonSafe(filePath) {
9
10
  try {
@@ -102,11 +103,12 @@ export async function buildProjectContext(cwd) {
102
103
  const scripts = await detectAvailableScripts(cwd);
103
104
  const projectType = await detectProjectType(cwd);
104
105
  // Gather context in parallel
105
- const [gitCtx, memory, rules, projectConfig] = await Promise.all([
106
+ const [gitCtx, memory, rules, projectConfig, boardCtx] = await Promise.all([
106
107
  collectGitInfo(cwd).then(gitInfoToContext).catch(() => ''),
107
108
  loadMemory(cwd).then(memoryToContext).catch(() => ''),
108
109
  loadScopedRules(cwd).then(rulesToContext).catch(() => ''),
109
110
  loadProjectConfig(cwd),
111
+ readBoardFile(cwd).then(boardToContext).catch(() => ''),
110
112
  ]);
111
113
  // Project index (uses cache)
112
114
  let indexCtx = '';
@@ -121,6 +123,7 @@ export async function buildProjectContext(cwd) {
121
123
  projectIndex: indexCtx,
122
124
  projectMemory: memory,
123
125
  scopedRules: rules,
126
+ boardContext: boardCtx,
124
127
  availableScripts: scripts,
125
128
  projectConfig,
126
129
  };
@@ -145,6 +148,11 @@ export function contextToInstructions(baseInstructions, ctx) {
145
148
  if (ctx.projectMemory) {
146
149
  sections.push(ctx.projectMemory);
147
150
  }
151
+ // After memory (durable knowledge), before rules: the board is the freshest
152
+ // section and the one a member should act on first when they conflict.
153
+ if (ctx.boardContext) {
154
+ sections.push(ctx.boardContext);
155
+ }
148
156
  if (ctx.scopedRules) {
149
157
  sections.push(ctx.scopedRules);
150
158
  }
@@ -77,7 +77,7 @@ export class CostTracker {
77
77
  // only attaches x-scalix-cost-cents to non-streaming responses (the
78
78
  // headers are already flushed by the time a stream's usage frame
79
79
  // arrives), so a streamed session simply never learns the figure.
80
- lines.push(provider === 'openai'
80
+ lines.push(provider && provider !== 'scalix'
81
81
  ? 'Est. cost: not reported by your endpoint — check your provider\'s billing dashboard.'
82
82
  : 'Est. cost: not reported on streamed turns — see console.scalix.world → Billing.');
83
83
  }
@@ -0,0 +1,51 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { USER_CONFIG_DIR, VERSION } from './constants.js';
6
+ import { logger } from './logger.js';
7
+ const FEEDBACK_FILE = path.join(process.env.HOME || process.env.USERPROFILE || '.', USER_CONFIG_DIR, 'feedback.json');
8
+ export async function saveFeedback(item) {
9
+ const fullItem = {
10
+ id: randomUUID(),
11
+ timestamp: new Date().toISOString(),
12
+ systemInfo: item.systemInfo || {
13
+ version: VERSION,
14
+ nodeVersion: process.version,
15
+ platform: os.platform(),
16
+ arch: os.arch(),
17
+ },
18
+ ...item,
19
+ };
20
+ try {
21
+ await fs.mkdir(path.dirname(FEEDBACK_FILE), { recursive: true });
22
+ let existing = [];
23
+ try {
24
+ const raw = await fs.readFile(FEEDBACK_FILE, 'utf-8');
25
+ const parsed = JSON.parse(raw);
26
+ if (Array.isArray(parsed))
27
+ existing = parsed;
28
+ }
29
+ catch { }
30
+ existing.unshift(fullItem);
31
+ // Keep last 100 feedback submissions locally
32
+ if (existing.length > 100)
33
+ existing = existing.slice(0, 100);
34
+ await fs.writeFile(FEEDBACK_FILE, JSON.stringify(existing, null, 2), { encoding: 'utf-8', mode: 0o600 });
35
+ logger.info('Feedback recorded locally', { id: fullItem.id, category: fullItem.category });
36
+ }
37
+ catch (err) {
38
+ logger.warn('Failed to persist feedback locally', { error: err.message });
39
+ }
40
+ return fullItem;
41
+ }
42
+ export async function listFeedback() {
43
+ try {
44
+ const raw = await fs.readFile(FEEDBACK_FILE, 'utf-8');
45
+ const parsed = JSON.parse(raw);
46
+ return Array.isArray(parsed) ? parsed : [];
47
+ }
48
+ catch {
49
+ return [];
50
+ }
51
+ }