tinywebui 0.1.1 → 0.1.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.
@@ -20,6 +20,9 @@ import { FEATURE } from '../../public/shared/features.js';
20
20
  /** Every feature a role can be granted. Each maps to UI and API routes. */
21
21
  export const FEATURES = Object.values(FEATURE);
22
22
 
23
+ /** What a signed-in user may personalise (access.customize). Model choice is the model-picker feature's. */
24
+ export const CUSTOMIZABLE = ['theme', 'instructions'];
25
+
23
26
  export const ROLES = ['admin', 'user'];
24
27
  export const STATUSES = ['pending', 'approved', 'disabled'];
25
28
 
@@ -37,10 +40,16 @@ const DEFAULT_ROLES = {
37
40
  export const FILE_ONLY = new Set([
38
41
  'authMode', 'authPassword', 'sessionSecret', 'sessionTtlDays', 'trustedProxyCidrs', 'trustedUserIdHeader',
39
42
  'trustedEmailHeader', 'trustedNameHeader', 'trustedRoleHeader',
40
- 'logoutUrl', 'baseUrl', 'apiKey', 'dbPath', 'access', 'models', 'allowedOrigins', 'frozen', 'autoMigrate', 'retrieval',
43
+ 'logoutUrl', 'dbPath', 'access', 'allowedOrigins', 'frozen', 'autoMigrate', 'retrieval',
41
44
  'googleClientId', 'googleClientSecret', 'googleRedirectUri', 'adminEmails'
42
45
  ]);
43
46
 
47
+ /**
48
+ * Writable, but where credentials and routing live: in a multi-user
49
+ * deployment only an admin may send these, whatever features a role has.
50
+ */
51
+ export const SECRET_PATCH_KEYS = new Set(['baseUrl', 'apiKey', 'models', 'connectors']);
52
+
44
53
  // Never logged, never fingerprinted as values, never sent to a browser.
45
54
  export const SECRET_KEYS = new Set(['apiKey', 'authPassword', 'sessionSecret', 'googleClientSecret']);
46
55
 
@@ -66,6 +75,7 @@ export function resolveAccess(cfg) {
66
75
  return {
67
76
  bootstrapAdmins: Array.isArray(a.bootstrapAdmins) ? a.bootstrapAdmins.map(String) : [],
68
77
  newUsers: a.newUsers || 'approved',
78
+ customize: Array.isArray(a.customize) ? a.customize : ['theme'],
69
79
  roles,
70
80
  users: a.users && typeof a.users === 'object' ? a.users : {}
71
81
  };
@@ -93,6 +103,8 @@ export function modelsFor(cfg, role) {
93
103
  return m === '*' || m === undefined ? '*' : m.map(String);
94
104
  }
95
105
 
106
+ export const MIN_PASSWORD_LENGTH = 15;
107
+
96
108
  /** Every problem with a config, as readable strings. Empty means valid. */
97
109
  export function validateConfig(cfg, { env = process.env } = {}) {
98
110
  const errors = [];
@@ -104,17 +116,19 @@ export function validateConfig(cfg, { env = process.env } = {}) {
104
116
  errors.push("authMode 'trusted-header' requires trustedProxyCidrs: identity headers are only believed from those peers");
105
117
  }
106
118
  if (mode === 'single') {
107
- if (cfg.authPassword && !String(cfg.authPassword).startsWith('scrypt$')) {
119
+ if (cfg.authPassword && !/^scrypt\$[0-9a-f]{32}\$[0-9a-f]{64}$/i.test(String(cfg.authPassword))) {
108
120
  errors.push('authPassword must be a hash: run `tinywebui set-password` or set $TINYWEBUI_PASSWORD');
109
121
  } else if (!cfg.authPassword && !env.TINYWEBUI_PASSWORD) {
110
122
  errors.push("authMode 'single' needs a password: run `tinywebui set-password` or set $TINYWEBUI_PASSWORD");
123
+ } else if (!cfg.authPassword && env.TINYWEBUI_PASSWORD.length < MIN_PASSWORD_LENGTH) {
124
+ errors.push(`TINYWEBUI_PASSWORD needs at least ${MIN_PASSWORD_LENGTH} characters`);
111
125
  }
112
126
  }
113
127
 
114
128
  const a = cfg.access;
115
129
  if (a !== undefined) {
116
130
  if (!a || typeof a !== 'object' || Array.isArray(a)) return [...errors, 'access must be an object'];
117
- const known = new Set(['bootstrapAdmins', 'newUsers', 'roles', 'users']);
131
+ const known = new Set(['bootstrapAdmins', 'newUsers', 'roles', 'users', 'customize']);
118
132
  for (const k of Object.keys(a)) if (!known.has(k)) errors.push(`access.${k} is not a known setting`);
119
133
  if (a.bootstrapAdmins !== undefined && !(Array.isArray(a.bootstrapAdmins) && a.bootstrapAdmins.every((x) => typeof x === 'string' && x))) {
120
134
  errors.push('access.bootstrapAdmins must be a list of gateway user ids');
@@ -122,6 +136,9 @@ export function validateConfig(cfg, { env = process.env } = {}) {
122
136
  if (a.newUsers !== undefined && !['approved', 'pending'].includes(a.newUsers)) {
123
137
  errors.push("access.newUsers must be 'approved' or 'pending'");
124
138
  }
139
+ if (a.customize !== undefined && !(Array.isArray(a.customize) && a.customize.every((c) => CUSTOMIZABLE.includes(c)))) {
140
+ errors.push(`access.customize must be a list from ${CUSTOMIZABLE.join(', ')}`);
141
+ }
125
142
  for (const [role, spec] of Object.entries(a.roles || {})) {
126
143
  if (!ROLES.includes(role)) { errors.push(`access.roles.${role}: unknown role (known: ${ROLES.join(', ')})`); continue; }
127
144
  if (!spec || typeof spec !== 'object') { errors.push(`access.roles.${role} must be an object`); continue; }
@@ -169,6 +186,9 @@ export function fingerprint(cfg, mcpServers = {}) {
169
186
  if (k === 'sessionSecret') continue;
170
187
  redacted[k] = SECRET_KEYS.has(k) ? Boolean(v) : v;
171
188
  }
189
+ if (Array.isArray(redacted.connectors)) {
190
+ redacted.connectors = redacted.connectors.map((c) => (c && typeof c === 'object' ? { ...c, apiKey: Boolean(c.apiKey) } : c));
191
+ }
172
192
  return createHash('sha256').update(canonical({ config: redacted, mcpServers })).digest('hex').slice(0, 16);
173
193
  }
174
194
 
package/src/chat/runs.js CHANGED
@@ -43,7 +43,7 @@ export function createRuns(app) {
43
43
  * `lead` is a queued follow-up that starts this run: stored after baseCount
44
44
  * and sent as an event, so a live view and a reload each show it once.
45
45
  */
46
- function start({ chat, tools, onFinish, historyFromSeq = null, unattended = false, model = null, lead = null }) {
46
+ function start({ chat, tools, onFinish, historyFromSeq = null, unattended = false, model = null, lead = null, instructions = null }) {
47
47
  const run = {
48
48
  events: [],
49
49
  subs: new Set(),
@@ -127,7 +127,12 @@ export function createRuns(app) {
127
127
 
128
128
  run.promise = (async () => {
129
129
  try {
130
- await runChat({ cfg: effectiveConfig(app.cfg, model), chatId: chat.id, store, tools, hub: app.hub, emit, signal: run.ac.signal, historyFromSeq, unattended, approve, takeInput, askUser });
130
+ const base = effectiveConfig(app.cfg, model);
131
+ const cfg = instructions ? { ...base, systemPrompt: `${base.systemPrompt}
132
+
133
+ The user's own instructions:
134
+ ${instructions}` } : base;
135
+ await runChat({ cfg, chatId: chat.id, store, tools, hub: app.hub, emit, signal: run.ac.signal, historyFromSeq, unattended, approve, takeInput, askUser });
131
136
  } catch (err) {
132
137
  emit({ type: EVENT.ERROR, error: run.ac.signal.aborted ? 'Stopped.' : err.message });
133
138
  } finally {
package/src/cli.js CHANGED
@@ -4,10 +4,10 @@ import { pipeline } from 'node:stream/promises';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { PRESETS, resolveModel, findOnnxFile, sha256File, resolveRetrieval, defaultModelsDir } from './retrieval/embedding.js';
6
6
  import { createConfigSource, DEFAULTS, WRITABLE, configProblems } from './config/config.js';
7
- import { REASONING_REPLAY } from './config/models.js';
7
+ import { REASONING_REPLAY, MODEL_TAGS } from './config/models.js';
8
8
  import {
9
9
  validateConfig, fingerprint, featuresFor, modelsFor, resolveAccess, keyClass,
10
- FEATURES, ROLES, STATUSES, FILE_ONLY, SECRET_KEYS
10
+ FEATURES, ROLES, STATUSES, FILE_ONLY, SECRET_KEYS, CUSTOMIZABLE
11
11
  } from './access/policy.js';
12
12
  import { Store, SCHEMA_VERSION, MigrationRequiredError } from './store/index.js';
13
13
 
@@ -426,8 +426,16 @@ export function configSchema() {
426
426
  }
427
427
  };
428
428
  const num = { type: ['number', 'null'] };
429
+ properties.connectors = {
430
+ type: 'array', 'x-change': 'editable (UI writes it back here)',
431
+ description: 'Named endpoints a catalog entry can use. apiKey is a secret: prefer an env var in committed config.',
432
+ items: {
433
+ type: 'object', additionalProperties: false, required: ['id', 'baseUrl'],
434
+ properties: { id: { type: 'string' }, label: { type: 'string' }, baseUrl: { type: 'string' }, apiKey: { type: 'string' } }
435
+ }
436
+ };
429
437
  properties.models = {
430
- type: 'array', 'x-change': 'file-only',
438
+ type: 'array', 'x-change': 'editable (UI writes it back here)',
431
439
  description: 'The model catalog. Empty: any model id. Listed: only these can be picked, by id, shown by label.',
432
440
  items: {
433
441
  type: 'object', additionalProperties: false, required: ['id'],
@@ -437,6 +445,8 @@ export function configSchema() {
437
445
  label: { type: 'string', description: 'What people see in the picker.' },
438
446
  description: { type: 'string' },
439
447
  enabled: { type: 'boolean', description: 'false: keep the settings, hide and refuse the model.' },
448
+ connector: { type: 'string', description: 'Id of an entry in connectors; absent: the default baseUrl/apiKey.' },
449
+ tags: { type: 'array', items: { enum: MODEL_TAGS }, uniqueItems: true, description: 'What the model can do.' },
440
450
  systemPrompt: { type: 'string' },
441
451
  temperature: num,
442
452
  maxTokens: num,
@@ -453,6 +463,7 @@ export function configSchema() {
453
463
  properties: {
454
464
  bootstrapAdmins: { type: 'array', items: { type: 'string' }, description: 'Gateway user ids that are always approved admins.' },
455
465
  newUsers: { enum: ['approved', 'pending'] },
466
+ customize: { type: 'array', items: { enum: CUSTOMIZABLE }, uniqueItems: true, description: 'What users may personalise.' },
456
467
  roles: { type: 'object', additionalProperties: false, properties: Object.fromEntries(ROLES.map((r) => [r, role])) },
457
468
  users: {
458
469
  type: 'object',
@@ -13,7 +13,9 @@
13
13
  * 'off' never ask
14
14
  *
15
15
  * Built-in tools (expand_context, read_document, manage_automation) are the
16
- * harness's own and always run; everything here applies to MCP tools.
16
+ * harness's own and always run; everything here applies to MCP tools. The one
17
+ * exception is a built-in that edits the MCP servers themselves (manage_mcp):
18
+ * it is gated, and asks before any call that changes something.
17
19
  *
18
20
  * Per-tool overrides win over the mode: `confirmTools` always asks,
19
21
  * `autoApproveTools` never does.
@@ -24,7 +26,12 @@ export const APPROVAL_MODES = ['writes', 'all', 'off'];
24
26
  export function approvalFor(cfg, hub, name, args) {
25
27
  // The harness's own tools are part of the harness, not third-party code:
26
28
  // they never ask, whatever the mode or overrides say. Approval is for MCP.
27
- if (hub?.isLocal?.(name)) return 'auto';
29
+ if (hub?.isLocal?.(name)) {
30
+ if (!hub.isGated?.(name, args)) return 'auto';
31
+ // manage_mcp can add a server that runs a command on this machine, so it
32
+ // asks even when the mode is 'off'. Only an explicit per-tool "auto" skips it.
33
+ return (cfg.autoApproveTools || []).includes(name) ? 'auto' : 'ask';
34
+ }
28
35
  if ((cfg.confirmTools || []).includes(name)) return 'ask';
29
36
  if ((cfg.autoApproveTools || []).includes(name)) return 'auto';
30
37
  // Missing means off, not the config default: a caller that builds its own
@@ -1,8 +1,8 @@
1
- import { readFileSync, existsSync, writeFileSync } from 'node:fs';
1
+ import { readFileSync, existsSync, writeFileSync, chmodSync } from 'node:fs';
2
2
  import { resolve, dirname } from 'node:path';
3
3
  import { randomBytes } from 'node:crypto';
4
4
  import { FILE_ONLY, mergeAccess, keyClass, validateConfig } from '../access/policy.js';
5
- import { modelProblems, labelFor, REASONING_REPLAY } from './models.js';
5
+ import { modelProblems, connectorProblems, labelFor, REASONING_REPLAY } from './models.js';
6
6
  import { logger } from '../log.js';
7
7
 
8
8
  const log = logger('config');
@@ -26,6 +26,9 @@ export const DEFAULTS = {
26
26
  // only these can be picked, `model` names one by id, and people see each
27
27
  // entry's label, with its own prompt and sampling settings if it has them.
28
28
  models: [],
29
+ // Named endpoints a catalog entry can point at (`connector: <id>`): each has
30
+ // its own baseUrl and apiKey. Entries without one use baseUrl/apiKey above.
31
+ connectors: [],
29
32
  systemPrompt: [
30
33
  'You are a direct, technically precise assistant.',
31
34
  '',
@@ -140,15 +143,13 @@ export const DEFAULTS = {
140
143
  // is offered to the model; a server can still be reached by name, so this
141
144
  // is a per-tool block, not a substitute for disabling the whole server.
142
145
  disabledTools: [],
143
- // Optional auth/RBAC. Off by default -- TinyWebUI stays single-user and
144
- // local-first unless this is deliberately opted into for a managed deploy.
145
- // 'none' no login: just you, on localhost (default)
146
+ // 'none' explicit local-only use without login
146
147
  // 'single' just you, behind a password (authPassword or
147
148
  // $TINYWEBUI_PASSWORD; set it with `tinywebui set-password`)
148
149
  // 'trusted-header' many users: an upstream gateway (Cloudflare Access,
149
150
  // oauth2-proxy, Authelia, ...) signs people in with
150
151
  // Google, Apple, SSO etc. and passes identity headers
151
- authMode: 'none',
152
+ authMode: 'single',
152
153
  // Level 'single': an scrypt hash (never plaintext), from `tinywebui
153
154
  // set-password`; never writable through /api/config.
154
155
  authPassword: '',
@@ -246,7 +247,8 @@ export function createConfigSource(opts = {}) {
246
247
  };
247
248
  const writeFile = (obj) => {
248
249
  if (!file) { memory = obj; return; }
249
- writeFileSync(file, JSON.stringify(obj, null, 2) + '\n');
250
+ if (existsSync(file) && process.platform !== 'win32') chmodSync(file, 0o600);
251
+ writeFileSync(file, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 });
250
252
  };
251
253
 
252
254
  function load({ persistSecret = true } = {}) {
@@ -290,7 +292,7 @@ export function createConfigSource(opts = {}) {
290
292
  const blocked = Object.keys(patch).filter((k) => WRITABLE.has(k) && locked.has(k));
291
293
  if (blocked.length) throw new LockedError(`set in code, not editable here: ${blocked.join(', ')}`);
292
294
  const current = readFile();
293
- for (const [k, v] of Object.entries(patch)) {
295
+ for (const [k, v] of Object.entries(mergeSecrets(patch, current))) {
294
296
  if (WRITABLE.has(k)) current[k] = v;
295
297
  }
296
298
  // Validate what the file would become before writing it.
@@ -420,7 +422,7 @@ export function configProblems(cfg) {
420
422
  if (!ENUMS[key].includes(value)) problems.push(`${key} must be one of ${ENUMS[key].join(', ')} (got ${JSON.stringify(value)})`);
421
423
  continue;
422
424
  }
423
- if (key === 'models') continue; // modelProblems, below, knows the shape
425
+ if (key === 'models' || key === 'connectors') continue; // modelProblems/connectorProblems, below, know the shape
424
426
  if (key === 'retrieval') {
425
427
  problems.push(...retrievalProblems(value));
426
428
  continue;
@@ -446,7 +448,7 @@ export function configProblems(cfg) {
446
448
  : got === want;
447
449
  if (!ok) problems.push(`${key} must be ${want === 'null' ? 'a number or null' : `a${want === 'array' || want === 'object' ? 'n' : ''} ${want}`} (got ${got})`);
448
450
  }
449
- return [...problems, ...modelProblems(cfg), ...validateConfig(cfg)];
451
+ return [...problems, ...connectorProblems(cfg), ...modelProblems(cfg), ...validateConfig(cfg)];
450
452
  }
451
453
 
452
454
  // Only the knobs the UI is allowed to change. Secrets stay server-side.
@@ -454,13 +456,54 @@ export const WRITABLE = new Set([
454
456
  'model', 'systemPrompt', 'temperature', 'maxTokens', 'maxToolRounds', 'askUserTimeoutSeconds',
455
457
  'cacheTtl', 'cacheMode', 'compactThreshold', 'keepTurns', 'maxInlineChars',
456
458
  'compactMinSaved', 'maxTurnChars', 'maxHistoryTokens', 'timezone',
457
- 'toolApproval', 'confirmTools', 'autoApproveTools', 'disabledTools'
459
+ 'toolApproval', 'confirmTools', 'autoApproveTools', 'disabledTools',
460
+ // The deployment's endpoints and catalog: only the sole user or an admin may
461
+ // send these (see SECRET_PATCH_KEYS in policy.js); keys are write-only.
462
+ 'baseUrl', 'apiKey', 'models', 'connectors',
463
+ 'llmCompaction', 'compactionMaxTokens', 'expandCharBudget', 'cache', 'reasoningReplay', 'extraBody'
458
464
  ]);
459
465
 
466
+ // A short tail is enough to tell two keys apart in a list; short keys show none.
467
+ const keyHint = (key) => (key && key.length >= 12 ? key.slice(-4) : null);
468
+
469
+ /**
470
+ * API keys are write-only: the page learns that one is set (and its last four
471
+ * characters), never the value. On save, a connector sent without `apiKey`
472
+ * keeps its stored key; `apiKey: null` clears it.
473
+ */
474
+ export function mergeSecrets(patch, current) {
475
+ const out = { ...patch };
476
+ if ('apiKey' in out && (out.apiKey === undefined || out.apiKey === '')) delete out.apiKey;
477
+ else if (out.apiKey === null) out.apiKey = '';
478
+ if (Array.isArray(out.connectors)) {
479
+ const old = new Map((Array.isArray(current.connectors) ? current.connectors : []).map((c) => [c?.id, c]));
480
+ out.connectors = out.connectors.map((c) => {
481
+ if (!c || typeof c !== 'object') return c;
482
+ const { apiKey, ...rest } = c;
483
+ if (apiKey === null) return { ...rest, apiKey: '' };
484
+ if (apiKey === undefined || apiKey === '') return old.get(c.id)?.apiKey ? { ...rest, apiKey: old.get(c.id).apiKey } : rest;
485
+ return c;
486
+ });
487
+ }
488
+ return out;
489
+ }
490
+
491
+ /** The patch as it may be logged: secret values replaced by whether they were set. */
492
+ export function redactSecrets(patch) {
493
+ const out = { ...patch };
494
+ if ('apiKey' in out) out.apiKey = Boolean(out.apiKey);
495
+ if (Array.isArray(out.connectors)) {
496
+ out.connectors = out.connectors.map((c) => (c && typeof c === 'object' && 'apiKey' in c ? { ...c, apiKey: Boolean(c.apiKey) } : c));
497
+ }
498
+ return out;
499
+ }
500
+
460
501
  export function publicConfig(cfg) {
461
- const { apiKey, authPassword, sessionSecret, googleClientSecret, ...rest } = cfg;
502
+ const { apiKey, authPassword, sessionSecret, googleClientSecret, connectors, ...rest } = cfg;
462
503
  return {
463
504
  ...rest,
505
+ connectors: (Array.isArray(connectors) ? connectors : []).map(({ apiKey: key, ...c }) => ({ ...c, hasApiKey: Boolean(key), keyHint: keyHint(key) })),
506
+ keyHint: keyHint(apiKey),
464
507
  modelLabel: labelFor(cfg, cfg.model),
465
508
  hasApiKey: Boolean(apiKey),
466
509
  hasGoogleAuth: Boolean(cfg.googleClientId),
@@ -23,7 +23,14 @@ export const MODEL_OVERRIDES = ['systemPrompt', 'temperature', 'maxTokens', 'max
23
23
  /** Values of `reasoningReplay`; see reasoningReplay in harness/provider.js. */
24
24
  export const REASONING_REPLAY = ['auto', 'omit', 'reasoning_content', 'reasoning_details'];
25
25
 
26
- const ENTRY_KEYS = new Set(['id', 'model', 'label', 'description', 'enabled', ...MODEL_OVERRIDES]);
26
+ /** What a model can do; the page uses these (e.g. `vision` gates image upload). */
27
+ export const MODEL_TAGS = ['vision', 'reasoning', 'tools'];
28
+
29
+ const ENTRY_KEYS = new Set(['id', 'model', 'label', 'description', 'enabled', 'connector', 'tags', ...MODEL_OVERRIDES]);
30
+
31
+ /** Named endpoints: { id, label?, baseUrl, apiKey? }. The top-level baseUrl/apiKey stay the implicit default. */
32
+ export const connectors = (cfg) => (Array.isArray(cfg.connectors) ? cfg.connectors : []);
33
+ export const findConnector = (cfg, id) => connectors(cfg).find((c) => c.id === id) || null;
27
34
 
28
35
  export const catalog = (cfg) => (Array.isArray(cfg.models) ? cfg.models : []);
29
36
  // Closed whenever entries are listed, even if all are disabled: parking every
@@ -44,7 +51,7 @@ export function labelFor(cfg, key) {
44
51
  }
45
52
 
46
53
  /** What the picker lists for one entry. Prompts and knobs stay server-side. */
47
- export const publicEntry = (e) => ({ id: e.id, name: e.label || e.id, description: e.description || null });
54
+ export const publicEntry = (e) => ({ id: e.id, name: e.label || e.id, description: e.description || null, tags: e.tags || [] });
48
55
 
49
56
  /**
50
57
  * The config one turn runs with: the entry's overrides over the global
@@ -63,6 +70,38 @@ export function effectiveConfig(cfg, key) {
63
70
  out.extraBody.provider = { ...(cfg.extraBody?.provider || {}), ...(e.extraBody?.provider || {}) };
64
71
  }
65
72
  out.model = e.model || e.id;
73
+ // The entry's endpoint replaces the global one; provider.js only ever reads
74
+ // cfg.baseUrl and cfg.apiKey, so nothing downstream knows about connectors.
75
+ const conn = e.connector ? findConnector(cfg, e.connector) : null;
76
+ if (conn) {
77
+ out.baseUrl = String(conn.baseUrl).replace(/\/+$/, '');
78
+ out.apiKey = conn.apiKey || '';
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /** Every problem with the connector list. */
84
+ export function connectorProblems(cfg) {
85
+ const list = cfg.connectors;
86
+ if (list === undefined) return [];
87
+ if (!Array.isArray(list)) return ['connectors must be a list of { id, baseUrl, apiKey?, label? } entries'];
88
+ const out = [];
89
+ const ids = new Set();
90
+ list.forEach((c, i) => {
91
+ const at = `connectors[${i}]`;
92
+ if (!c || typeof c !== 'object' || Array.isArray(c)) { out.push(`${at} must be an object`); return; }
93
+ if (typeof c.id !== 'string' || !c.id.trim()) { out.push(`${at}.id must be a non-empty string`); return; }
94
+ if (ids.has(c.id)) out.push(`${at}.id "${c.id}" is listed twice`);
95
+ ids.add(c.id);
96
+ for (const k of Object.keys(c)) if (!['id', 'label', 'baseUrl', 'apiKey'].includes(k)) out.push(`${at}.${k} is not a known setting (known: id, label, baseUrl, apiKey)`);
97
+ for (const k of ['label', 'apiKey']) if (c[k] !== undefined && typeof c[k] !== 'string') out.push(`${at}.${k} must be a string`);
98
+ try {
99
+ if (!/^https?:$/.test(new URL(c.baseUrl).protocol)) throw new Error('scheme');
100
+ } catch { out.push(`${at}.baseUrl must be an http(s) URL`); }
101
+ });
102
+ for (const [i, e] of (Array.isArray(cfg.models) ? cfg.models : []).entries()) {
103
+ if (e?.connector !== undefined && !ids.has(e.connector)) out.push(`models[${i}].connector "${e.connector}" is not in connectors`);
104
+ }
66
105
  return out;
67
106
  }
68
107
 
@@ -93,6 +132,8 @@ export function modelProblems(cfg) {
93
132
  if (e.reasoningReplay !== undefined && !REASONING_REPLAY.includes(e.reasoningReplay)) out.push(`${at}.reasoningReplay must be one of ${REASONING_REPLAY.join(', ')}`);
94
133
  if (e.cacheTtl !== undefined && !['5m', '1h'].includes(e.cacheTtl)) out.push(`${at}.cacheTtl must be 5m or 1h`);
95
134
  if (e.enabled !== undefined && typeof e.enabled !== 'boolean') out.push(`${at}.enabled must be true or false`);
135
+ if (e.connector !== undefined && typeof e.connector !== 'string') out.push(`${at}.connector must be a connector id`);
136
+ if (e.tags !== undefined && !(Array.isArray(e.tags) && e.tags.every((t) => MODEL_TAGS.includes(t)))) out.push(`${at}.tags must be a list from ${MODEL_TAGS.join(', ')}`);
96
137
  if (e.extraBody !== undefined && !(e.extraBody && typeof e.extraBody === 'object' && !Array.isArray(e.extraBody))) out.push(`${at}.extraBody must be an object`);
97
138
  });
98
139
  if (!list.length || out.length) return out;
package/src/mcp.js CHANGED
@@ -13,7 +13,25 @@ const MAX_NAME = 64;
13
13
 
14
14
  // Built-in tools registered after connect(). An MCP tool that flattened onto
15
15
  // one of these would be shadowed by the local handler without a word.
16
- const RESERVED = new Set(['expand_context', 'read_document', 'manage_automation', 'ask_user', 'manage_tasks', 'search_chats']);
16
+ const RESERVED = new Set(['expand_context', 'read_document', 'manage_automation', 'ask_user', 'manage_tasks', 'search_chats', 'manage_mcp']);
17
+
18
+ const REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
19
+
20
+ /**
21
+ * Fills `${NAME}` from the environment, so a key can live outside mcp.json (and
22
+ * out of any chat that set the server up). A name that is not set fails the
23
+ * connection with its name, never a silent empty string: an empty bearer token
24
+ * reads as "unauthorised" and sends people looking in the wrong place.
25
+ */
26
+ export function expandRefs(value, env = process.env) {
27
+ if (typeof value !== 'string') return value;
28
+ return value.replace(REF, (_, name) => {
29
+ if (env[name] === undefined) throw new Error(`mcp.json refers to \${${name}}, which is not set in TinyWebUI's environment`);
30
+ return env[name];
31
+ });
32
+ }
33
+
34
+ const expandAll = (obj) => obj && Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, expandRefs(v)]));
17
35
 
18
36
  /**
19
37
  * The flat name the model sees for a server's tool. Sanitising and the
@@ -87,8 +105,8 @@ function transportFor(name, spec) {
87
105
  if (spec.command) {
88
106
  return new StdioClientTransport({
89
107
  command: spec.command,
90
- args: spec.args || [],
91
- env: { ...process.env, ...(spec.env || {}) },
108
+ args: (spec.args || []).map((a) => expandRefs(a)),
109
+ env: { ...process.env, ...(expandAll(spec.env) || {}) },
92
110
  cwd: spec.cwd,
93
111
  // Piped rather than inherited so we can label it and, more importantly,
94
112
  // drop the shutdown noise -- a stdio server killed by Ctrl-C prints a full
@@ -97,8 +115,8 @@ function transportFor(name, spec) {
97
115
  });
98
116
  }
99
117
  if (spec.url) {
100
- const opts = spec.headers ? { requestInit: { headers: spec.headers } } : undefined;
101
- const url = new URL(spec.url);
118
+ const opts = spec.headers ? { requestInit: { headers: expandAll(spec.headers) } } : undefined;
119
+ const url = new URL(expandRefs(spec.url));
102
120
  return spec.transport === 'sse'
103
121
  ? new SSEClientTransport(url, opts)
104
122
  : new StreamableHTTPClientTransport(url, opts);
@@ -126,6 +144,9 @@ export class McpHub {
126
144
  // default policy: name -> true, or a function of the call's arguments.
127
145
  // Same opt-in rule -- silence means "may write".
128
146
  this.readOnly = new Map();
147
+ // Local tools that change the deployment itself and so ask every time,
148
+ // unlike the other built-ins: name -> true, or a function of the arguments.
149
+ this.gated = new Map();
129
150
  // Explicit 'parallel' | 'sequential' overrides for tools whose safety to
130
151
  // run alongside others is known better than the hints above say.
131
152
  this.modes = new Map();
@@ -142,8 +163,9 @@ export class McpHub {
142
163
  * Registered after connect() so locals always land at the end of the block,
143
164
  * keeping the tool list byte-identical between runs.
144
165
  */
145
- registerLocal(def, handler, { readOnly = false, idempotent = readOnly === true, executionMode = null } = {}) {
166
+ registerLocal(def, handler, { readOnly = false, idempotent = readOnly === true, executionMode = null, gated = false } = {}) {
146
167
  this.locals.set(def.function.name, handler);
168
+ if (gated) this.gated.set(def.function.name, gated);
147
169
  if (executionMode) this.modes.set(def.function.name, executionMode);
148
170
  if (readOnly) this.readOnly.set(def.function.name, readOnly);
149
171
  if (idempotent) this.idempotent.add(def.function.name);
@@ -335,6 +357,12 @@ export class McpHub {
335
357
  return this.locals.has(flatName);
336
358
  }
337
359
 
360
+ /** True when this call is to a local tool that must ask, whatever the approval mode. */
361
+ isGated(flatName, args) {
362
+ const v = this.gated.get(flatName);
363
+ return typeof v === 'function' ? Boolean(v(args || {})) : Boolean(v);
364
+ }
365
+
338
366
  /** True when this call is declared to change nothing. */
339
367
  isReadOnly(flatName, args) {
340
368
  const v = this.readOnly.get(flatName);
@@ -1,6 +1,7 @@
1
1
  import { json, readJson } from '../http.js';
2
2
  import { ALL_USERS, toView } from '../store/index.js';
3
- import { validateConfig, fingerprint, resolveAccess } from '../access/policy.js';
3
+ import { validateConfig, fingerprint, resolveAccess, FEATURES, CUSTOMIZABLE } from '../access/policy.js';
4
+ import { catalog, modelProblems } from '../config/models.js';
4
5
  import { destroyUserSessions } from '../access/auth.js';
5
6
  import { audit } from '../audit.js';
6
7
  import { FEATURE } from '../../public/shared/features.js';
@@ -77,6 +78,42 @@ export function adminRoutes({ config, multiUser, refreshConfig, runs, source, st
77
78
  return json(res, 200, { user: adminUserView(updated) });
78
79
  }) },
79
80
 
81
+ // What the `user` role may do, what new users start as, and what users may
82
+ // personalise. The admin role is not editable here: it cannot be locked
83
+ // out of the panel that edits it. Written to the file like every decision.
84
+ { method: 'GET', path: /^\/api\/admin\/policy$/, feature: FEATURE.ADMIN, handle: multi(({ res }) => {
85
+ const a = resolveAccess(config());
86
+ return json(res, 200, {
87
+ features: FEATURES, customizable: CUSTOMIZABLE, models: catalog(config()).map((m) => m.id),
88
+ user: a.roles.user, newUsers: a.newUsers, customize: a.customize,
89
+ locked: source.lockedKeys().has('access')
90
+ });
91
+ }) },
92
+
93
+ { method: 'POST', path: /^\/api\/admin\/policy$/, feature: FEATURE.ADMIN, handle: multi(async ({ req, res, auth }) => {
94
+ if (source.isFrozen() || source.lockedKeys().has('access')) return json(res, 409, { error: 'access is set in code or frozen: change the files instead' });
95
+ const { features, models, newUsers, customize } = await readJson(req);
96
+ const file = source.readFile();
97
+ const access = file.access && typeof file.access === 'object' ? file.access : {};
98
+ const user = { ...(access.roles?.user || {}) };
99
+ if (features !== undefined) user.features = features;
100
+ if (models !== undefined) user.models = models;
101
+ const merged = {
102
+ ...access,
103
+ roles: { ...(access.roles || {}), user },
104
+ ...(newUsers !== undefined ? { newUsers } : {}),
105
+ ...(customize !== undefined ? { customize } : {})
106
+ };
107
+ const next = { ...file, access: merged };
108
+ const problems = [...validateConfig({ ...config(), access: merged }), ...modelProblems({ ...config(), access: merged })];
109
+ if (problems.length) return json(res, 400, { error: problems.join('; ') });
110
+ source.writeFile(next);
111
+ refreshConfig();
112
+ audit('admin.policy_changed', { by: auth.userId, user, newUsers: merged.newUsers ?? null, customize: merged.customize ?? null });
113
+ const a = resolveAccess(config());
114
+ return json(res, 200, { user: a.roles.user, newUsers: a.newUsers, customize: a.customize });
115
+ }) },
116
+
80
117
  // Oversight: read-only, every view audited. Reached through ALL_USERS on
81
118
  // purpose -- this is the one place a user's scope is crossed, and it only
82
119
  // exists when there are users to cross between.
@@ -1,6 +1,6 @@
1
1
  import { json, readJson } from '../http.js';
2
2
  import { isClosed, findEntry, labelFor } from '../config/models.js';
3
- import { modelsFor, fingerprint } from '../access/policy.js';
3
+ import { modelsFor, fingerprint, resolveAccess } from '../access/policy.js';
4
4
  import {
5
5
  verifyPassword, createSession, destroySession, sessionToken, sessionCookie, clearCookie, isSecureRequest
6
6
  } from '../access/auth.js';
@@ -24,10 +24,15 @@ export function authRoutes({ config, modelFor, multiUser, passwordHash, source,
24
24
  authMode: cfg.authMode,
25
25
  logoutUrl: cfg.logoutUrl || '',
26
26
  isAdmin: Boolean(auth.isAdmin),
27
+ // What this person may personalise, and their saved instructions (multi-user only).
28
+ customize: cfg.authMode === 'trusted-header' && auth.user ? resolveAccess(cfg).customize : [],
29
+ instructions: cfg.authMode === 'trusted-header' && auth.user ? store.users.getPrefs(auth.user.id).instructions || '' : '',
27
30
  features: [...auth.features],
28
31
  models: signedIn ? modelsFor(cfg, auth.role) : [],
29
32
  model,
30
33
  modelLabel: signedIn ? labelFor(cfg, model) : null,
34
+ // What the catalog says this model can do; empty when it says nothing.
35
+ modelTags: signedIn ? findEntry(cfg, model)?.tags || [] : [],
31
36
  user: auth.user
32
37
  ? { id: auth.user.id, email: auth.user.email, name: auth.user.name ?? null, role: auth.user.role, status: auth.user.status }
33
38
  : null
@@ -71,10 +76,17 @@ export function authRoutes({ config, modelFor, multiUser, passwordHash, source,
71
76
 
72
77
  // Tier 3's model pill: a personal choice among the role's models. In
73
78
  // tiers 1-2 the pill changes the configured model instead (/api/config).
74
- { method: 'POST', path: /^\/api\/me\/prefs$/, feature: FEATURE.MODEL_PICKER, handle: async ({ req, res, auth }) => {
79
+ { method: 'POST', path: /^\/api\/me\/prefs$/, feature: null, handle: async ({ req, res, auth }) => {
75
80
  const cfg = config();
76
81
  if (!multiUser()) return json(res, 400, { error: 'preferences are per-user; set the model in settings' });
77
- const { model } = await readJson(req);
82
+ const { model, instructions } = await readJson(req);
83
+ if (instructions !== undefined) {
84
+ if (!resolveAccess(cfg).customize.includes('instructions')) return json(res, 403, { error: 'your administrator has not enabled personal instructions' });
85
+ if (instructions !== null && (typeof instructions !== 'string' || instructions.length > 4000)) return json(res, 400, { error: 'instructions must be text of at most 4000 characters, or null' });
86
+ store.users.setPref(auth.user.id, 'instructions', instructions && instructions.trim() ? instructions.trim() : null);
87
+ if (model === undefined) return json(res, 200, { instructions: instructions && instructions.trim() ? instructions.trim() : '' });
88
+ }
89
+ if (!auth.features.has(FEATURE.MODEL_PICKER)) return json(res, 403, { error: 'feature not available to your role', feature: FEATURE.MODEL_PICKER });
78
90
  if (model !== null && (typeof model !== 'string' || !model.trim())) return json(res, 400, { error: 'model must be a model id or null' });
79
91
  const allowed = modelsFor(cfg, auth.role);
80
92
  if (model && isClosed(cfg) && !findEntry(cfg, model)) return json(res, 403, { error: 'that model is not available to you' });
@@ -17,7 +17,7 @@ function refusedFeature(auth, { images, documentIds }) {
17
17
  return null;
18
18
  }
19
19
 
20
- export function chatRoutes({ config, modelFor, runs, saveConfig, store, toolsFor }) {
20
+ export function chatRoutes({ config, instructionsFor, modelFor, runs, saveConfig, store, toolsFor }) {
21
21
  const noChat = (res) => json(res, 404, { error: 'no such chat' });
22
22
 
23
23
  return [
@@ -105,7 +105,7 @@ export function chatRoutes({ config, modelFor, runs, saveConfig, store, toolsFor
105
105
  // rewound transcript and then attaches, the same path a reload takes,
106
106
  // rather than reading a stream through a response it also has to
107
107
  // redraw behind.
108
- runs.start({ chat: found, tools: toolsFor(auth.features), model: modelFor(auth.user?.id, auth.role) });
108
+ runs.start({ chat: found, tools: toolsFor(auth.features), model: modelFor(auth.user?.id, auth.role), instructions: instructionsFor(auth.user?.id) });
109
109
  return json(res, 200, { ok: true, running: true });
110
110
  } },
111
111
 
@@ -225,7 +225,7 @@ export function chatRoutes({ config, modelFor, runs, saveConfig, store, toolsFor
225
225
  const existing = runs.get(chat.id);
226
226
  const run = existing && !existing.done
227
227
  ? existing
228
- : runs.start({ chat, tools: toolsFor(auth.features), model: modelFor(auth.user?.id, auth.role) });
228
+ : runs.start({ chat, tools: toolsFor(auth.features), model: modelFor(auth.user?.id, auth.role), instructions: instructionsFor(auth.user?.id) });
229
229
  return runs.attach(run, res, 0);
230
230
  } }
231
231
  ];