tinywebui 0.1.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/LICENSE +661 -0
- package/README.md +586 -0
- package/bin/tinywebui.js +105 -0
- package/package.json +49 -0
- package/public/app.js +81 -0
- package/public/chat/attachments.js +304 -0
- package/public/chat/chat.js +282 -0
- package/public/chat/composer.js +289 -0
- package/public/chat/library.js +76 -0
- package/public/chat/outline.js +39 -0
- package/public/chat/queue.js +124 -0
- package/public/chat/sidebar.js +485 -0
- package/public/chat/stream.js +162 -0
- package/public/chat/tasks.js +36 -0
- package/public/chat/transcript.js +691 -0
- package/public/core/access.js +42 -0
- package/public/core/api.js +27 -0
- package/public/core/dom.js +13 -0
- package/public/core/id.js +7 -0
- package/public/core/md.js +131 -0
- package/public/core/state.js +31 -0
- package/public/favicon.svg +9 -0
- package/public/index.html +261 -0
- package/public/panels/admin.js +199 -0
- package/public/panels/automations.js +464 -0
- package/public/panels/panels.js +52 -0
- package/public/panels/settings.js +365 -0
- package/public/panels/usage.js +474 -0
- package/public/shared/attachment_note.js +23 -0
- package/public/shared/events.js +27 -0
- package/public/shared/features.js +21 -0
- package/public/shared/origins.js +8 -0
- package/public/shared/usage.js +59 -0
- package/public/styles.css +1455 -0
- package/public/themes/README.md +178 -0
- package/public/themes/cyber-grid/cyber-grid.css +59 -0
- package/public/themes/drivers/scene-kit.js +422 -0
- package/public/themes/fall-fairy/fall-fairy.css +221 -0
- package/public/themes/fall-fairy/fall-fairy.js +859 -0
- package/public/themes/manifest.json +6 -0
- package/public/themes/theme-loader.js +123 -0
- package/src/access/auth.js +228 -0
- package/src/access/auth_gate.js +67 -0
- package/src/access/policy.js +180 -0
- package/src/audit.js +7 -0
- package/src/automations/automation.js +136 -0
- package/src/automations/scheduler.js +147 -0
- package/src/chat/compact.js +242 -0
- package/src/chat/llm.js +448 -0
- package/src/chat/runs.js +180 -0
- package/src/chat/tool_executor.js +239 -0
- package/src/cli.js +478 -0
- package/src/config/approval.js +54 -0
- package/src/config/config.js +479 -0
- package/src/config/models.js +112 -0
- package/src/files/documents.js +91 -0
- package/src/files/images.js +62 -0
- package/src/harness/agent.js +115 -0
- package/src/harness/attribution.js +75 -0
- package/src/harness/checkpoint.js +96 -0
- package/src/harness/history.js +40 -0
- package/src/harness/prompt.js +93 -0
- package/src/harness/provider.js +430 -0
- package/src/http.js +95 -0
- package/src/log.js +24 -0
- package/src/mcp.js +405 -0
- package/src/retrieval/embedding.js +298 -0
- package/src/retrieval/retrieval.js +250 -0
- package/src/routes/admin.js +112 -0
- package/src/routes/auth.js +97 -0
- package/src/routes/automations.js +92 -0
- package/src/routes/chats.js +232 -0
- package/src/routes/documents.js +133 -0
- package/src/routes/library.js +51 -0
- package/src/routes/mcp.js +59 -0
- package/src/routes/settings.js +101 -0
- package/src/server.js +397 -0
- package/src/set-password.js +32 -0
- package/src/store/automations.js +119 -0
- package/src/store/chats.js +254 -0
- package/src/store/documents.js +256 -0
- package/src/store/embeddings.js +67 -0
- package/src/store/index.js +90 -0
- package/src/store/messages.js +93 -0
- package/src/store/schema.js +480 -0
- package/src/store/scope.js +43 -0
- package/src/store/search.js +56 -0
- package/src/store/turns.js +78 -0
- package/src/store/usage.js +327 -0
- package/src/store/users.js +102 -0
- package/src/store/wire.js +55 -0
- package/src/tools/ask_tool.js +93 -0
- package/src/tools/chat_search_tool.js +63 -0
- package/src/tools/context_tool.js +183 -0
- package/src/tools/document_tool.js +102 -0
- package/src/tools/task_tool.js +63 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { json, readJson, UPLOAD_LIMIT } from '../http.js';
|
|
2
|
+
import { LockedError } from '../config/config.js';
|
|
3
|
+
import { fingerprint } from '../access/policy.js';
|
|
4
|
+
import { actor } from '../access/auth_gate.js';
|
|
5
|
+
import { toolList } from './settings.js';
|
|
6
|
+
import { logger } from '../log.js';
|
|
7
|
+
import { FEATURE } from '../../public/shared/features.js';
|
|
8
|
+
|
|
9
|
+
const log = logger('mcp');
|
|
10
|
+
|
|
11
|
+
export function mcpRoutes({ auditMcp, config, hub, source, swapHub }) {
|
|
12
|
+
|
|
13
|
+
/** Writes mcp.json (audited), then reconnects. A locked or invalid file is the caller's error. */
|
|
14
|
+
const saveAndReconnect = async (res, text, auth) => {
|
|
15
|
+
let servers;
|
|
16
|
+
try {
|
|
17
|
+
const before = fingerprint(config(), source.loadMcpServers());
|
|
18
|
+
servers = source.saveMcpFile(text);
|
|
19
|
+
auditMcp(actor(auth), before);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
json(res, err instanceof LockedError ? 409 : 400, { error: err.message });
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
await swapHub(servers);
|
|
25
|
+
log.info(`reloaded: ${hub().tools.length} tool(s)`);
|
|
26
|
+
return true;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return [
|
|
30
|
+
// mcp.json can hold server credentials (env, headers): admins only.
|
|
31
|
+
{ method: 'GET', path: /^\/api\/mcp$/, feature: FEATURE.MCP, handle: ({ res }) =>
|
|
32
|
+
json(res, 200, { path: source.mcpPath(), text: source.readMcpFile(), locked: source.mcpLocked }) },
|
|
33
|
+
|
|
34
|
+
{ method: 'POST', path: /^\/api\/mcp$/, feature: FEATURE.MCP, handle: async ({ req, res, auth }) => {
|
|
35
|
+
const { text } = await readJson(req, UPLOAD_LIMIT);
|
|
36
|
+
if (!await saveAndReconnect(res, text, auth)) return undefined;
|
|
37
|
+
return json(res, 200, { tools: toolList(hub()), mcpErrors: hub().errors });
|
|
38
|
+
} },
|
|
39
|
+
|
|
40
|
+
// Disabling a server tears its connection down rather than filtering its
|
|
41
|
+
// tools out client-side: an unwanted server is one fewer child process or
|
|
42
|
+
// open connection, not just one the model happens not to be offered.
|
|
43
|
+
{ method: 'POST', path: /^\/api\/mcp\/servers\/([^/]+)\/toggle$/, feature: FEATURE.MCP, handle: async ({ req, res, auth, params: [rawName] }) => {
|
|
44
|
+
const name = decodeURIComponent(rawName);
|
|
45
|
+
const { disabled } = await readJson(req);
|
|
46
|
+
let servers;
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(source.readMcpFile());
|
|
49
|
+
servers = parsed.mcpServers || parsed;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return json(res, 500, { error: `mcp.json is not valid JSON: ${err.message}` });
|
|
52
|
+
}
|
|
53
|
+
if (!servers[name]) return json(res, 404, { error: `no such server "${name}"` });
|
|
54
|
+
servers[name] = { ...servers[name], disabled: Boolean(disabled) };
|
|
55
|
+
if (!await saveAndReconnect(res, JSON.stringify({ mcpServers: servers }, null, 2), auth)) return undefined;
|
|
56
|
+
return json(res, 200, { ...hub().inventory(config().disabledTools), disabledTools: config().disabledTools || [] });
|
|
57
|
+
} }
|
|
58
|
+
];
|
|
59
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { json, readJson } from '../http.js';
|
|
2
|
+
import { isClosed, enabledEntries, publicEntry } from '../config/models.js';
|
|
3
|
+
import { modelsFor } from '../access/policy.js';
|
|
4
|
+
import { audit } from '../audit.js';
|
|
5
|
+
import { setOverride } from '../config/approval.js';
|
|
6
|
+
import { actor } from '../access/auth_gate.js';
|
|
7
|
+
import { FEATURE } from '../../public/shared/features.js';
|
|
8
|
+
|
|
9
|
+
export const toolList = (hub) => hub.tools.map((t) => ({
|
|
10
|
+
name: t.function.name,
|
|
11
|
+
description: t.function.description,
|
|
12
|
+
parameters: t.function.parameters
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
export function settingsRoutes({ config, configFor, hub, saveConfig, toolsView }) {
|
|
16
|
+
// The provider's model list, cached per endpoint for a few minutes:
|
|
17
|
+
// OpenRouter's list is large and the picker is opened far more often than
|
|
18
|
+
// the catalogue changes.
|
|
19
|
+
let modelCache = null;
|
|
20
|
+
const listModels = async () => {
|
|
21
|
+
const cfg = config();
|
|
22
|
+
const key = `${cfg.baseUrl}|${Boolean(cfg.apiKey)}`;
|
|
23
|
+
if (modelCache?.key === key && Date.now() - modelCache.at < 5 * 60_000) return modelCache.value;
|
|
24
|
+
let value;
|
|
25
|
+
try {
|
|
26
|
+
const r = await fetch(`${cfg.baseUrl}/models`, {
|
|
27
|
+
headers: cfg.apiKey ? { authorization: `Bearer ${cfg.apiKey}` } : {},
|
|
28
|
+
signal: AbortSignal.timeout(8000)
|
|
29
|
+
});
|
|
30
|
+
if (!r.ok) throw new Error(`${r.status} from ${cfg.baseUrl}/models`);
|
|
31
|
+
const body = await r.json();
|
|
32
|
+
const rows = Array.isArray(body?.data) ? body.data : Array.isArray(body?.models) ? body.models : [];
|
|
33
|
+
const models = rows
|
|
34
|
+
.map((m) => ({ id: m.id || m.name, name: m.name && m.name !== m.id ? m.name : null }))
|
|
35
|
+
.filter((m) => typeof m.id === 'string' && m.id)
|
|
36
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
37
|
+
value = { supported: models.length > 0, models };
|
|
38
|
+
} catch (err) {
|
|
39
|
+
value = { supported: false, models: [], error: err.message };
|
|
40
|
+
}
|
|
41
|
+
modelCache = { key, at: Date.now(), value };
|
|
42
|
+
return value;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return [
|
|
46
|
+
{ method: 'GET', path: /^\/api\/config$/, feature: null, handle: ({ res, auth }) => json(res, 200, {
|
|
47
|
+
...configFor(auth),
|
|
48
|
+
tools: toolList(hub()),
|
|
49
|
+
mcpErrors: hub().errors
|
|
50
|
+
}) },
|
|
51
|
+
|
|
52
|
+
{ method: 'POST', path: /^\/api\/config$/, feature: FEATURE.SETTINGS, handle: async ({ req, res, auth }) => {
|
|
53
|
+
saveConfig(await readJson(req), actor(auth));
|
|
54
|
+
audit('admin.config_changed', { by: auth.userId ?? null });
|
|
55
|
+
return json(res, 200, configFor(auth));
|
|
56
|
+
} },
|
|
57
|
+
|
|
58
|
+
// The provider's own model list, for the composer's model picker. Not
|
|
59
|
+
// every OpenAI-compatible endpoint serves /models, so a failure is an
|
|
60
|
+
// answer too: the picker falls back to typing an id.
|
|
61
|
+
{ method: 'GET', path: /^\/api\/models$/, feature: FEATURE.MODEL_PICKER, handle: async ({ res, auth }) => {
|
|
62
|
+
const cfg = config();
|
|
63
|
+
const allowed = modelsFor(cfg, auth.role);
|
|
64
|
+
// A catalog is the whole list: its labels, never the provider's ids.
|
|
65
|
+
if (isClosed(cfg)) {
|
|
66
|
+
const entries = enabledEntries(cfg).filter((e) => allowed === '*' || allowed.includes(e.id));
|
|
67
|
+
return json(res, 200, { supported: true, restricted: true, catalog: true, models: entries.map(publicEntry) });
|
|
68
|
+
}
|
|
69
|
+
const all = await listModels();
|
|
70
|
+
if (allowed === '*') return json(res, 200, all);
|
|
71
|
+
// A fixed catalog: only those ids, named from the provider list when
|
|
72
|
+
// it knows them, listed even when it does not.
|
|
73
|
+
const byId = new Map(all.models.map((m) => [m.id, m]));
|
|
74
|
+
return json(res, 200, { supported: true, restricted: true, models: allowed.map((id) => byId.get(id) || { id, name: null }) });
|
|
75
|
+
} },
|
|
76
|
+
|
|
77
|
+
// The grouped view behind the tools panel: built-ins, and every MCP
|
|
78
|
+
// server's tools under it with that server's connection health.
|
|
79
|
+
{ method: 'GET', path: /^\/api\/tools$/, feature: FEATURE.TOOLS, handle: ({ res }) => json(res, 200, toolsView()) },
|
|
80
|
+
|
|
81
|
+
// Per-tool approval override from the settings panel: 'ask', 'auto', or
|
|
82
|
+
// 'default' to fall back to the global toolApproval mode.
|
|
83
|
+
{ method: 'POST', path: /^\/api\/tools\/approval$/, feature: FEATURE.TOOLS, handle: async ({ req, res, auth }) => {
|
|
84
|
+
const { name, policy } = await readJson(req);
|
|
85
|
+
if (!name || !['ask', 'auto', 'default'].includes(policy)) {
|
|
86
|
+
return json(res, 400, { error: 'name and policy (ask | auto | default) are required' });
|
|
87
|
+
}
|
|
88
|
+
saveConfig(setOverride(config(), name, policy), actor(auth));
|
|
89
|
+
return json(res, 200, toolsView());
|
|
90
|
+
} },
|
|
91
|
+
|
|
92
|
+
{ method: 'POST', path: /^\/api\/tools\/toggle$/, feature: FEATURE.TOOLS, handle: async ({ req, res, auth }) => {
|
|
93
|
+
const { name, disabled } = await readJson(req);
|
|
94
|
+
if (!name) return json(res, 400, { error: 'name is required' });
|
|
95
|
+
const set = new Set(config().disabledTools || []);
|
|
96
|
+
if (disabled) set.add(name); else set.delete(name);
|
|
97
|
+
saveConfig({ disabledTools: [...set] }, actor(auth));
|
|
98
|
+
return json(res, 200, toolsView());
|
|
99
|
+
} }
|
|
100
|
+
];
|
|
101
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { watch, existsSync } from 'node:fs';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { createConfigSource, LockedError, configProblems } from './config/config.js';
|
|
7
|
+
import { McpHub } from './mcp.js';
|
|
8
|
+
import { isClosed, findEntry } from './config/models.js';
|
|
9
|
+
import { Store, SCHEMA_VERSION } from './store/index.js';
|
|
10
|
+
import { fingerprint, featuresFor, modelsFor } from './access/policy.js';
|
|
11
|
+
import { hashPassword, applyAccessPolicy } from './access/auth.js';
|
|
12
|
+
import { audit } from './audit.js';
|
|
13
|
+
import { logger } from './log.js';
|
|
14
|
+
import { resolveAuth, OWNER_ID } from './access/auth_gate.js';
|
|
15
|
+
import { json, HttpError, SECURITY_HEADERS, crossSite, serveStatic } from './http.js';
|
|
16
|
+
import { expandToolDef, callExpand } from './tools/context_tool.js';
|
|
17
|
+
import { documentToolDef, callReadDocument } from './tools/document_tool.js';
|
|
18
|
+
import { askToolDef, callAskUser } from './tools/ask_tool.js';
|
|
19
|
+
import { chatSearchToolDef, callSearchChats } from './tools/chat_search_tool.js';
|
|
20
|
+
import { taskToolDef, callManageTasks } from './tools/task_tool.js';
|
|
21
|
+
import { automationToolDef, manageAutomation } from './automations/automation.js';
|
|
22
|
+
import { overrideFor } from './config/approval.js';
|
|
23
|
+
import { Retrieval } from './retrieval/retrieval.js';
|
|
24
|
+
import { loadEmbedder, resolveRetrieval, defaultModelsDir } from './retrieval/embedding.js';
|
|
25
|
+
import { createRuns } from './chat/runs.js';
|
|
26
|
+
import { createScheduler } from './automations/scheduler.js';
|
|
27
|
+
import { authRoutes } from './routes/auth.js';
|
|
28
|
+
import { settingsRoutes } from './routes/settings.js';
|
|
29
|
+
import { mcpRoutes } from './routes/mcp.js';
|
|
30
|
+
import { libraryRoutes } from './routes/library.js';
|
|
31
|
+
import { automationRoutes } from './routes/automations.js';
|
|
32
|
+
import { documentRoutes } from './routes/documents.js';
|
|
33
|
+
import { chatRoutes } from './routes/chats.js';
|
|
34
|
+
import { adminRoutes } from './routes/admin.js';
|
|
35
|
+
import { EVENT } from '../public/shared/events.js';
|
|
36
|
+
import { FEATURE } from '../public/shared/features.js';
|
|
37
|
+
|
|
38
|
+
const log = logger('server');
|
|
39
|
+
|
|
40
|
+
const VERSION = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every API route, with the feature it needs (null: any signed-in caller, or
|
|
44
|
+
* anyone for the pre-auth routes the auth gate lets through). Dispatch and
|
|
45
|
+
* permission are the same table, so a route cannot ship without a decision
|
|
46
|
+
* about who may use it. First match wins.
|
|
47
|
+
*/
|
|
48
|
+
function routeTable(app) {
|
|
49
|
+
// What route groups may ask for. Each group's signature names the part it
|
|
50
|
+
// uses; config, hub and passwordHash change at runtime, so they are read
|
|
51
|
+
// through accessors rather than captured.
|
|
52
|
+
const deps = {
|
|
53
|
+
store: app.store, runs: app.runs, scheduler: app.scheduler, retrieval: app.retrieval,
|
|
54
|
+
source: app.source, version: app.version,
|
|
55
|
+
config: () => app.cfg, hub: () => app.hub, passwordHash: () => app.passwordHash,
|
|
56
|
+
saveConfig: (patch, by) => app.saveConfig(patch, by), refreshConfig: () => app.refreshConfig(),
|
|
57
|
+
auditMcp: (by, before) => app.auditMcp(by, before), swapHub: (servers) => app.swapHub(servers),
|
|
58
|
+
multiUser: () => app.multiUser(), modelFor: (userId, role) => app.modelFor(userId, role),
|
|
59
|
+
toolsFor: (features) => app.toolsFor(features), toolsView: () => app.toolsView(), configFor: (auth) => app.configFor(auth)
|
|
60
|
+
};
|
|
61
|
+
return [
|
|
62
|
+
...authRoutes(deps), ...settingsRoutes(deps), ...mcpRoutes(deps), ...libraryRoutes(deps),
|
|
63
|
+
...automationRoutes(deps), ...documentRoutes(deps), ...chatRoutes(deps), ...adminRoutes(deps)
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Handlers only read their dependencies when called, so a stub lists the table.
|
|
68
|
+
export const API_ROUTES = routeTable({}).map((r) => [r.method, r.path, r.feature]);
|
|
69
|
+
|
|
70
|
+
// Fields a non-admin never sees: credentials, and where the deployment's
|
|
71
|
+
// trust boundary sits.
|
|
72
|
+
const ADMIN_ONLY_FIELDS = /^(apiKey|authPassword|sessionSecret|google|baseUrl|trusted|adminEmails|models$)/;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Starts an instance. Everything but port/host is optional and passed to
|
|
76
|
+
* createConfigSource: `config` (keys set here win and are locked in the UI),
|
|
77
|
+
* `configFile` (a path, or false for none), `mcpServers`, `mcpFile`, `dbPath`.
|
|
78
|
+
*/
|
|
79
|
+
export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } = {}) {
|
|
80
|
+
const source = createConfigSource(sourceOpts);
|
|
81
|
+
const initial = source.load();
|
|
82
|
+
const problems = configProblems(initial);
|
|
83
|
+
if (problems.length) throw new Error(problems.join('\n'));
|
|
84
|
+
|
|
85
|
+
const store = new Store(source.dbPath(initial), { migrate: initial.autoMigrate !== false });
|
|
86
|
+
// Runs live in this process, so nothing from before it can still be waiting.
|
|
87
|
+
store.chats.expireQuestions();
|
|
88
|
+
if (store.migratedFrom !== null && store.migratedFrom < SCHEMA_VERSION) {
|
|
89
|
+
log.info(`migrated database from schema ${store.migratedFrom} to ${SCHEMA_VERSION}`);
|
|
90
|
+
}
|
|
91
|
+
if (initial.authMode === 'trusted-header') applyAccessPolicy(store, initial);
|
|
92
|
+
|
|
93
|
+
// The embedding bundle loads now, so a problem with it stops startup with a
|
|
94
|
+
// clear message instead of surfacing on the first search -- except under
|
|
95
|
+
// 'auto', where a model that simply isn't installed means lexical.
|
|
96
|
+
let resolved;
|
|
97
|
+
try {
|
|
98
|
+
const load = sourceOpts.embedder ? async () => sourceOpts.embedder : loadEmbedder;
|
|
99
|
+
resolved = await resolveRetrieval(initial.retrieval, defaultModelsDir(source.path()), load);
|
|
100
|
+
} catch (err) { store.close(); throw err; }
|
|
101
|
+
const { embedder } = resolved;
|
|
102
|
+
if (resolved.fallback) log.warn(`retrieval: lexical only -- ${resolved.fallback}`);
|
|
103
|
+
else if (embedder) log.info(`retrieval: ${resolved.mode} with ${embedder.spec?.repoId || initial.retrieval.model} (${embedder.dim} dims)`);
|
|
104
|
+
|
|
105
|
+
if (initial.authMode === 'single') store.users.ensureOwner(OWNER_ID);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* What every part of the server shares. `cfg` and `hub` are replaced, not
|
|
109
|
+
* mutated, when settings are saved or files reload, so read them from here
|
|
110
|
+
* at use time rather than holding on to them.
|
|
111
|
+
*/
|
|
112
|
+
const app = {
|
|
113
|
+
version: VERSION,
|
|
114
|
+
source,
|
|
115
|
+
store,
|
|
116
|
+
retrieval: new Retrieval(store, { ...initial.retrieval, mode: resolved.mode }, embedder),
|
|
117
|
+
cfg: initial,
|
|
118
|
+
hub: null,
|
|
119
|
+
// 'single': the stored hash, or one made in memory from $TINYWEBUI_PASSWORD
|
|
120
|
+
// (handy for containers). validateConfig already refused a plaintext one.
|
|
121
|
+
passwordHash: initial.authMode === 'single' ? initial.authPassword || hashPassword(process.env.TINYWEBUI_PASSWORD) : null,
|
|
122
|
+
|
|
123
|
+
// Every UI/API change lands in config.json (source.save) and is audited:
|
|
124
|
+
// who, which keys, the new values (none of these keys are secrets), and the
|
|
125
|
+
// config fingerprint before and after, to line up with a git diff.
|
|
126
|
+
saveConfig(patch, by) {
|
|
127
|
+
const before = fingerprint(app.cfg, source.loadMcpServers());
|
|
128
|
+
app.cfg = source.save(patch);
|
|
129
|
+
audit('config.changed', { by: by ?? 'local', keys: Object.keys(patch), values: patch, before, after: fingerprint(app.cfg, source.loadMcpServers()) });
|
|
130
|
+
return app.cfg;
|
|
131
|
+
},
|
|
132
|
+
/** Re-reads the config after something other than saveConfig wrote the file. */
|
|
133
|
+
refreshConfig() {
|
|
134
|
+
app.cfg = source.load();
|
|
135
|
+
},
|
|
136
|
+
auditMcp: (by, before) => audit('mcp.changed', { by: by ?? 'local', before, after: fingerprint(app.cfg, source.loadMcpServers()) }),
|
|
137
|
+
|
|
138
|
+
multiUser: () => app.cfg.authMode === 'trusted-header',
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The model a person's turns use. Tiers 1-2: the configured model. Tier 3:
|
|
142
|
+
* their own pick if their role still allows it, else the configured model
|
|
143
|
+
* if allowed, else the first model their role allows.
|
|
144
|
+
*/
|
|
145
|
+
modelFor(userId, role) {
|
|
146
|
+
const { cfg } = app;
|
|
147
|
+
// With a catalog, everything is an entry id; a provider id (as the
|
|
148
|
+
// default `model` may be) is resolved to its entry.
|
|
149
|
+
const known = (m) => (isClosed(cfg) ? findEntry(cfg, m)?.id : m);
|
|
150
|
+
const fallback = known(cfg.model) || cfg.model;
|
|
151
|
+
if (cfg.authMode !== 'trusted-header' || !userId) return fallback;
|
|
152
|
+
const allowed = modelsFor(cfg, role);
|
|
153
|
+
const ok = (m) => m && (allowed === '*' || allowed.includes(m));
|
|
154
|
+
const pref = known(store.users.getPrefs(userId).model);
|
|
155
|
+
if (ok(pref)) return pref;
|
|
156
|
+
if (ok(fallback)) return fallback;
|
|
157
|
+
// First of the role's models still enabled (a parked one never runs).
|
|
158
|
+
return allowed.map(known).find(Boolean) || fallback;
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
/** The tools a turn gets: what is switched on, minus manage_automation for
|
|
162
|
+
* anyone whose role has no automations. */
|
|
163
|
+
toolsFor: (features) => app.hub.activeTools(app.cfg.disabledTools)
|
|
164
|
+
.filter((t) => features.has(FEATURE.AUTOMATIONS) || t.function.name !== 'manage_automation'),
|
|
165
|
+
|
|
166
|
+
/** A stored owner's features: null (tiers 1-2) is the one person. */
|
|
167
|
+
ownerFeatures: (userId) => featuresFor(app.cfg, userId ? store.users.get(userId)?.role : null),
|
|
168
|
+
|
|
169
|
+
/** The tools panel payload: inventory plus each tool's approval state. */
|
|
170
|
+
toolsView() {
|
|
171
|
+
const { cfg, hub } = app;
|
|
172
|
+
const inv = hub.inventory(cfg.disabledTools);
|
|
173
|
+
const mark = (t) => ({ ...t, approval: overrideFor(cfg, t.name) });
|
|
174
|
+
return {
|
|
175
|
+
// Built-ins never ask, so they carry no approval state to show.
|
|
176
|
+
internal: inv.internal,
|
|
177
|
+
servers: inv.servers.map((s) => ({ ...s, tools: s.tools.map(mark) })),
|
|
178
|
+
disabledTools: cfg.disabledTools || [],
|
|
179
|
+
toolApproval: cfg.toolApproval
|
|
180
|
+
};
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
configFor(auth) {
|
|
184
|
+
const pub = source.public(app.cfg);
|
|
185
|
+
if (auth.features.has(FEATURE.SETTINGS)) return pub;
|
|
186
|
+
const out = {};
|
|
187
|
+
for (const [k, v] of Object.entries(pub)) if (!ADMIN_ONLY_FIELDS.test(k)) out[k] = v;
|
|
188
|
+
return { ...out, readOnly: true };
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
/** Replaces the hub wholesale: the old one's child processes are shut
|
|
192
|
+
* down after the new one is up, so a rename cannot leave an orphan. */
|
|
193
|
+
swapHub(servers) {
|
|
194
|
+
// Recorded now, not once connected: the file watcher fires 300 ms after
|
|
195
|
+
// our own save, and a slow connect would make that echo look like an
|
|
196
|
+
// outside edit and start a second swap.
|
|
197
|
+
mcpText = source.readMcpFile();
|
|
198
|
+
// One swap at a time: two in flight would both close the same old hub
|
|
199
|
+
// and leave the first new one, and its child processes, running.
|
|
200
|
+
const run = swapping.then(async () => {
|
|
201
|
+
if (stopping) return;
|
|
202
|
+
const old = app.hub;
|
|
203
|
+
app.hub = await connectHub(servers);
|
|
204
|
+
await old.close();
|
|
205
|
+
});
|
|
206
|
+
swapping = run.catch(() => {});
|
|
207
|
+
return run;
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
onRunIdle: (chatId) => app.scheduler.drain(chatId)
|
|
211
|
+
};
|
|
212
|
+
app.runs = createRuns(app);
|
|
213
|
+
app.scheduler = createScheduler(app);
|
|
214
|
+
const { retrieval, runs, scheduler } = app;
|
|
215
|
+
|
|
216
|
+
// The built-in tools are ours, not an MCP server's, but the model should not
|
|
217
|
+
// be able to tell: they are registered onto the same hub and called the same way.
|
|
218
|
+
const connectHub = async (servers) =>
|
|
219
|
+
(await new McpHub(servers).connect())
|
|
220
|
+
.registerLocal(expandToolDef(), (args, ctx) => callExpand(args, { ...ctx, store }), { readOnly: true })
|
|
221
|
+
.registerLocal(documentToolDef(), (args, ctx) => callReadDocument(args, { ...ctx, store, retrieval }), { readOnly: true })
|
|
222
|
+
.registerLocal(chatSearchToolDef(), (args, ctx) => callSearchChats(args, { ...ctx, store, retrieval }), { readOnly: true })
|
|
223
|
+
// Changes nothing, so it never waits for approval; never alongside
|
|
224
|
+
// other calls, so a question cannot race a write it is asking about.
|
|
225
|
+
.registerLocal(askToolDef(), callAskUser, { readOnly: true, idempotent: false, executionMode: 'sequential' })
|
|
226
|
+
.registerLocal(taskToolDef(), (args, ctx) => callManageTasks(args, {
|
|
227
|
+
...ctx, store, onChange: () => runs.get(ctx.chatId)?.emit({ type: EVENT.TASKS, tasks: store.chats.visibleTasks(ctx.chatId) })
|
|
228
|
+
}), { executionMode: 'sequential' })
|
|
229
|
+
.registerLocal(automationToolDef(), (args, ctx) => {
|
|
230
|
+
const out = manageAutomation(args, { ...ctx, store, triggerAutomation: scheduler.trigger });
|
|
231
|
+
scheduler.arm();
|
|
232
|
+
return out;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
app.hub = await connectHub(source.loadMcpServers());
|
|
236
|
+
// What mcp.json said when the hub was last (re)built, so a reload can tell
|
|
237
|
+
// a real edit from the echo of the UI's own save.
|
|
238
|
+
let mcpText = source.readMcpFile();
|
|
239
|
+
// The hub swap in progress, if any, and whether shutdown has begun.
|
|
240
|
+
let swapping = Promise.resolve();
|
|
241
|
+
let stopping = false;
|
|
242
|
+
const serversOf = (text) => { try { const p = JSON.parse(text); return p.mcpServers || p; } catch { return {}; } };
|
|
243
|
+
|
|
244
|
+
log.info(`config: ${source.path() || '(in code, no file)'}`);
|
|
245
|
+
log.info(`mcp: ${source.mcpPath() || '(in code)'}`);
|
|
246
|
+
log.info(`db: ${source.dbPath(app.cfg)}`);
|
|
247
|
+
log.info(`model: ${app.cfg.model} via ${app.cfg.baseUrl}`);
|
|
248
|
+
log.info(`tools: ${app.hub.tools.length} from ${app.hub.clients.size} MCP server(s)`);
|
|
249
|
+
// Repeated after the banner, grouped, so it is the last thing a first run shows.
|
|
250
|
+
const offFor = Map.groupBy(app.hub.install, ([, need]) => need.url);
|
|
251
|
+
for (const [url, entries] of offFor) {
|
|
252
|
+
log.warn(`${entries.map(([name]) => name).join(', ')} off until ${entries[0][1].name} is installed: ${url}`);
|
|
253
|
+
}
|
|
254
|
+
log.info(`policy: ${fingerprint(app.cfg, source.loadMcpServers())} (fingerprint)`);
|
|
255
|
+
|
|
256
|
+
const routes = routeTable(app);
|
|
257
|
+
|
|
258
|
+
const server = createServer(async (req, res) => {
|
|
259
|
+
try {
|
|
260
|
+
for (const [k, v] of Object.entries(SECURITY_HEADERS)) res.setHeader(k, v);
|
|
261
|
+
const url = new URL(req.url || '/', 'http://x');
|
|
262
|
+
const { pathname } = url;
|
|
263
|
+
if (crossSite(req, app.cfg)) {
|
|
264
|
+
audit('request.cross_site_rejected', { method: req.method, path: pathname });
|
|
265
|
+
return json(res, 403, { error: 'cross-site request refused' });
|
|
266
|
+
}
|
|
267
|
+
// Probes for orchestrators: no auth, nothing sensitive. /healthz says
|
|
268
|
+
// the process answers; /readyz that it can serve (database reachable),
|
|
269
|
+
// and which build and config it serves.
|
|
270
|
+
if (req.method === 'GET' && pathname === '/healthz') return json(res, 200, { ok: true });
|
|
271
|
+
if (req.method === 'GET' && pathname === '/readyz') {
|
|
272
|
+
let dbOk = false;
|
|
273
|
+
try { dbOk = store.ping(); } catch { /* not ready */ }
|
|
274
|
+
return json(res, dbOk ? 200 : 503, { ready: dbOk, version: VERSION, fingerprint: fingerprint(app.cfg, source.loadMcpServers()) });
|
|
275
|
+
}
|
|
276
|
+
const auth = resolveAuth(req, pathname, app.cfg, store);
|
|
277
|
+
if (auth.notFound) return json(res, 404, { error: 'not found' });
|
|
278
|
+
if (auth.unauthorized) return json(res, 401, { error: 'unauthorized' });
|
|
279
|
+
if (auth.disabled) return json(res, 403, { error: 'disabled' });
|
|
280
|
+
if (auth.pending) return json(res, 403, { error: 'pending_approval' });
|
|
281
|
+
|
|
282
|
+
if (!pathname.startsWith('/api/')) return serveStatic(req, res);
|
|
283
|
+
for (const route of routes) {
|
|
284
|
+
if (route.method !== req.method) continue;
|
|
285
|
+
const match = route.path.exec(pathname);
|
|
286
|
+
if (!match) continue;
|
|
287
|
+
if (route.feature && !auth.features.has(route.feature)) return json(res, 403, { error: 'feature_disabled', feature: route.feature });
|
|
288
|
+
return await route.handle({ req, res, auth, url, params: match.slice(1) });
|
|
289
|
+
}
|
|
290
|
+
return json(res, 404, { error: 'not found' });
|
|
291
|
+
} catch (err) {
|
|
292
|
+
if (err instanceof HttpError && err.status === 413) req.resume?.();
|
|
293
|
+
if (!res.headersSent) json(res, err instanceof HttpError ? err.status : err instanceof LockedError ? 409 : 500, { error: err.message });
|
|
294
|
+
else res.end();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await new Promise((resolve) => server.listen(port, host, resolve));
|
|
299
|
+
scheduler.arm();
|
|
300
|
+
log.info(`http://${host}:${port}`);
|
|
301
|
+
// Anything stored before dense retrieval was on (or under another model)
|
|
302
|
+
// is embedded in the background; document queries fill in on demand too.
|
|
303
|
+
retrieval.backfill((msg) => log.info(`retrieval: ${msg}`))
|
|
304
|
+
.catch((err) => log.error(`retrieval backfill failed: ${err.message}`));
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Re-reads config.json and mcp.json after a hand or agent edit. All or
|
|
308
|
+
* nothing: a file that fails to parse or validate is reported and the
|
|
309
|
+
* running config stays exactly as it was. tinywebui.config.js is code and
|
|
310
|
+
* is only read at startup, as is authMode (switching it live would strand
|
|
311
|
+
* sessions and half the auth state).
|
|
312
|
+
*/
|
|
313
|
+
async function reloadFromFiles(reason) {
|
|
314
|
+
let next, servers, text;
|
|
315
|
+
try {
|
|
316
|
+
next = source.load();
|
|
317
|
+
text = source.readMcpFile();
|
|
318
|
+
const parsed = JSON.parse(text);
|
|
319
|
+
servers = parsed.mcpServers || parsed;
|
|
320
|
+
for (const [name, spec] of Object.entries(servers)) {
|
|
321
|
+
if (!spec || (!spec.command && !spec.url)) throw new Error(`mcp server "${name}" needs either "command" or "url"`);
|
|
322
|
+
}
|
|
323
|
+
} catch (err) {
|
|
324
|
+
log.warn(`reload rejected (${reason}): ${err.message}`);
|
|
325
|
+
audit('config.reload_rejected', { reason, problems: [err.message] });
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
const problems = configProblems(next);
|
|
329
|
+
if (next.authMode !== app.cfg.authMode) problems.push('authMode changes need a restart');
|
|
330
|
+
if (JSON.stringify(next.retrieval) !== JSON.stringify(app.cfg.retrieval)) problems.push('retrieval changes need a restart');
|
|
331
|
+
if (problems.length) {
|
|
332
|
+
log.warn(`reload rejected (${reason}):\n ${problems.join('\n ')}`);
|
|
333
|
+
audit('config.reload_rejected', { reason, problems });
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
const before = fingerprint(app.cfg, serversOf(mcpText));
|
|
337
|
+
const after = fingerprint(next, servers);
|
|
338
|
+
const mcpChanged = text !== mcpText;
|
|
339
|
+
if (before === after && !mcpChanged) return true; // our own write, or a no-op save
|
|
340
|
+
app.cfg = next;
|
|
341
|
+
if (next.authMode === 'single') app.passwordHash = next.authPassword || hashPassword(process.env.TINYWEBUI_PASSWORD);
|
|
342
|
+
if (next.authMode === 'trusted-header') applyAccessPolicy(store, next);
|
|
343
|
+
if (mcpChanged) await app.swapHub(servers);
|
|
344
|
+
scheduler.arm();
|
|
345
|
+
log.info(`reloaded (${reason}): policy ${after}`);
|
|
346
|
+
audit('config.reloaded', { reason, before, after, mcpChanged });
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Editors often replace a file rather than write into it, which a watch on
|
|
351
|
+
// the file itself would lose; watching the folder catches both.
|
|
352
|
+
const watchers = [];
|
|
353
|
+
let reloadTimer = null;
|
|
354
|
+
const scheduleReload = (reason) => {
|
|
355
|
+
clearTimeout(reloadTimer);
|
|
356
|
+
reloadTimer = setTimeout(() => { reloadFromFiles(reason).catch((err) => log.error(`reload failed: ${err.message}`)); }, 300);
|
|
357
|
+
reloadTimer.unref?.();
|
|
358
|
+
};
|
|
359
|
+
if (sourceOpts.watch !== false) {
|
|
360
|
+
const targets = [source.path(), source.mcpPath()].filter(Boolean);
|
|
361
|
+
for (const folder of new Set(targets.map((t) => dirname(t)))) {
|
|
362
|
+
if (!existsSync(folder)) continue;
|
|
363
|
+
const names = new Set(targets.filter((t) => dirname(t) === folder).map((t) => t.slice(folder.length + 1)));
|
|
364
|
+
try {
|
|
365
|
+
const w = watch(folder, (_, name) => { if (name && names.has(String(name))) scheduleReload('file changed'); });
|
|
366
|
+
w.unref?.();
|
|
367
|
+
watchers.push(w);
|
|
368
|
+
} catch (err) { log.error(`cannot watch ${folder}: ${err.message}`); }
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const onHup = () => scheduleReload('SIGHUP');
|
|
372
|
+
process.on('SIGHUP', onHup);
|
|
373
|
+
server.reload = reloadFromFiles;
|
|
374
|
+
|
|
375
|
+
// Real teardown, reused two ways: on SIGINT/SIGTERM it exits the process, and
|
|
376
|
+
// as `server.shutdown()` it does not -- which is what a test harness needs.
|
|
377
|
+
const shutdown = async () => {
|
|
378
|
+
scheduler.stop();
|
|
379
|
+
clearTimeout(reloadTimer);
|
|
380
|
+
for (const w of watchers) w.close();
|
|
381
|
+
process.off('SIGHUP', onHup);
|
|
382
|
+
// A swap still connecting would otherwise finish after this and leave
|
|
383
|
+
// its servers running.
|
|
384
|
+
stopping = true;
|
|
385
|
+
await swapping;
|
|
386
|
+
await app.hub.close();
|
|
387
|
+
await Promise.allSettled([...retrieval.pending.values()]);
|
|
388
|
+
embedder?.close?.();
|
|
389
|
+
store.close();
|
|
390
|
+
await new Promise((resolve) => server.close(resolve));
|
|
391
|
+
};
|
|
392
|
+
process.on('SIGINT', () => shutdown().then(() => process.exit(0)));
|
|
393
|
+
process.on('SIGTERM', () => shutdown().then(() => process.exit(0)));
|
|
394
|
+
|
|
395
|
+
server.shutdown = shutdown;
|
|
396
|
+
return server;
|
|
397
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { createConfigSource } from './config/config.js';
|
|
4
|
+
import { hashPassword } from './access/auth.js';
|
|
5
|
+
|
|
6
|
+
/** Reads one line without echoing it when attached to a terminal. */
|
|
7
|
+
function ask(prompt) {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY });
|
|
10
|
+
if (process.stdin.isTTY) rl._writeToOutput = (s) => { if (s.startsWith(prompt)) rl.output.write(prompt); };
|
|
11
|
+
rl.question(prompt, (answer) => { rl.close(); if (process.stdin.isTTY) process.stdout.write('\n'); resolve(answer); });
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `tinywebui set-password`: stores an scrypt hash of a new password in the
|
|
17
|
+
* config file and switches authMode to 'single'. Existing sessions are
|
|
18
|
+
* signed with sessionSecret, which is rotated so they all end here too.
|
|
19
|
+
*/
|
|
20
|
+
export async function setPassword() {
|
|
21
|
+
const file = createConfigSource().path();
|
|
22
|
+
if (!file) throw new Error('no config file to write to');
|
|
23
|
+
const pw = await ask('New password: ');
|
|
24
|
+
if (pw.length < 8) throw new Error('use at least 8 characters');
|
|
25
|
+
if (process.stdin.isTTY && (await ask('Repeat password: ')) !== pw) throw new Error('passwords do not match');
|
|
26
|
+
const current = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
|
|
27
|
+
current.authMode = 'single';
|
|
28
|
+
current.authPassword = hashPassword(pw);
|
|
29
|
+
delete current.sessionSecret; // regenerated on next start: every old session ends
|
|
30
|
+
writeFileSync(file, JSON.stringify(current, null, 2) + '\n');
|
|
31
|
+
console.log(`[tinywebui] password set in ${file}; restart TinyWebUI to apply`);
|
|
32
|
+
}
|