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.
- package/README.md +50 -519
- package/bin/tinywebui.js +16 -7
- package/package.json +3 -3
- package/public/chat/attachments.js +5 -1
- package/public/chat/composer.js +16 -6
- package/public/core/access.js +5 -0
- package/public/index.html +53 -0
- package/public/panels/admin.js +42 -0
- package/public/panels/connectors.js +184 -0
- package/public/panels/settings.js +68 -4
- package/public/styles.css +16 -0
- package/src/access/policy.js +23 -3
- package/src/chat/runs.js +7 -2
- package/src/cli.js +14 -3
- package/src/config/approval.js +9 -2
- package/src/config/config.js +55 -12
- package/src/config/models.js +43 -2
- package/src/mcp.js +34 -6
- package/src/routes/admin.js +38 -1
- package/src/routes/auth.js +15 -3
- package/src/routes/chats.js +3 -3
- package/src/routes/settings.js +34 -3
- package/src/server.js +70 -9
- package/src/set-password.js +18 -7
- package/src/store/schema.js +1 -1
- package/src/tools/mcp_tool.js +311 -0
package/src/access/policy.js
CHANGED
|
@@ -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', '
|
|
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 &&
|
|
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
|
-
|
|
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': '
|
|
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',
|
package/src/config/approval.js
CHANGED
|
@@ -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))
|
|
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
|
package/src/config/config.js
CHANGED
|
@@ -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
|
-
//
|
|
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: '
|
|
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
|
-
|
|
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,
|
|
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),
|
package/src/config/models.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/routes/admin.js
CHANGED
|
@@ -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.
|
package/src/routes/auth.js
CHANGED
|
@@ -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:
|
|
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' });
|
package/src/routes/chats.js
CHANGED
|
@@ -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
|
];
|