mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
[wip] Secrets manager extension
This commit is contained in:
@@ -2076,7 +2076,7 @@
|
||||
<h3 class="margin0" id="title_api">API</h3>
|
||||
<div class="flex-container flexFlowColumn">
|
||||
<div id="main-API-selector-block">
|
||||
<select id="main_api">
|
||||
<select id="main_api" class="flex1 text_pole">
|
||||
<option value="textgenerationwebui" data-i18n="Text Completion">Text Completion</option>
|
||||
<option value="openai" data-i18n="Chat Completion">Chat Completion</option>
|
||||
<option value="novel" data-i18n="NovelAI">NovelAI</option>
|
||||
|
@@ -497,6 +497,7 @@ export const event_types = {
|
||||
CONNECTION_PROFILE_LOADED: 'connection_profile_loaded',
|
||||
TOOL_CALLS_PERFORMED: 'tool_calls_performed',
|
||||
TOOL_CALLS_RENDERED: 'tool_calls_rendered',
|
||||
SECRET_WRITTEN: 'secret_written',
|
||||
};
|
||||
|
||||
export const eventSource = new EventEmitter();
|
||||
|
695
public/scripts/extensions/secrets-manager/index.js
Normal file
695
public/scripts/extensions/secrets-manager/index.js
Normal file
@@ -0,0 +1,695 @@
|
||||
import { event_types, eventSource, getRequestHeaders, main_api } from '../../../script.js';
|
||||
import { t } from '../../i18n.js';
|
||||
import { chat_completion_sources, oai_settings } from '../../openai.js';
|
||||
import { Popup, POPUP_RESULT } from '../../popup.js';
|
||||
import { readSecretState, SECRET_KEYS, secret_state, writeSecret } from '../../secrets.js';
|
||||
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
|
||||
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
|
||||
import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
|
||||
import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
|
||||
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
|
||||
import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';
|
||||
import { textgen_types, textgenerationwebui_settings } from '../../textgen-settings.js';
|
||||
import { isTrueBoolean } from '../../utils.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('../../slash-commands/SlashCommandExecutor.js').SlashCommandExecutor} Executor
|
||||
* @typedef {import('../../slash-commands/SlashCommand.js').NamedArguments | import('../../slash-commands/SlashCommand.js').NamedArgumentsCapture} Args
|
||||
* @typedef {import('../../../../src/endpoints/secrets.js').ManagedKeyState} ManagedKeyState
|
||||
* @type {import('../../../../src/endpoints/secrets.js').SecretManagerState}
|
||||
*/
|
||||
let MANAGER_STATE = {};
|
||||
|
||||
const getKeyComment = () => `key-${new Date().toISOString().split('.')[0].replace(/[-:TZ]/g, '')}`;
|
||||
|
||||
/**
|
||||
* Lookup table for secret keys corresponding to API and type.
|
||||
*/
|
||||
const KEY_LOOKUP = [
|
||||
{ api: 'novel', type: null, key : SECRET_KEYS.NOVEL },
|
||||
{ api: 'koboldhorde', type: null, key: SECRET_KEYS.HORDE },
|
||||
{ api: 'openai', type: chat_completion_sources.AI21, key: SECRET_KEYS.AI21 },
|
||||
{ api: 'openai', type: chat_completion_sources.BLOCKENTROPY, key: SECRET_KEYS.BLOCKENTROPY },
|
||||
{ api: 'openai', type: chat_completion_sources.COHERE, key: SECRET_KEYS.COHERE },
|
||||
{ api: 'openai', type: chat_completion_sources.CLAUDE, key: SECRET_KEYS.CLAUDE },
|
||||
{ api: 'openai', type: chat_completion_sources.CUSTOM, key: SECRET_KEYS.CUSTOM },
|
||||
{ api: 'openai', type: chat_completion_sources.DEEPSEEK, key: SECRET_KEYS.DEEPSEEK },
|
||||
{ api: 'openai', type: chat_completion_sources.GROQ, key: SECRET_KEYS.GROQ },
|
||||
{ api: 'openai', type: chat_completion_sources.MAKERSUITE, key: SECRET_KEYS.MAKERSUITE },
|
||||
{ api: 'openai', type: chat_completion_sources.MISTRALAI, key: SECRET_KEYS.MISTRALAI },
|
||||
{ api: 'openai', type: chat_completion_sources.NANOGPT, key: SECRET_KEYS.NANOGPT },
|
||||
{ api: 'openai', type: chat_completion_sources.OPENAI, key: SECRET_KEYS.OPENAI },
|
||||
{ api: 'openai', type: chat_completion_sources.OPENROUTER, key: SECRET_KEYS.OPENROUTER },
|
||||
{ api: 'openai', type: chat_completion_sources.PERPLEXITY, key: SECRET_KEYS.PERPLEXITY },
|
||||
{ api: 'openai', type: chat_completion_sources.SCALE, key: SECRET_KEYS.SCALE },
|
||||
{ api: 'openai', type: chat_completion_sources.ZEROONEAI, key: SECRET_KEYS.ZEROONEAI },
|
||||
{ api: 'textgenerationwebui', type: textgen_types.APHRODITE, key: SECRET_KEYS.APHRODITE },
|
||||
{ api: 'textgenerationwebui', type: textgen_types.DREAMGEN, key: SECRET_KEYS.DREAMGEN },
|
||||
{ api: 'textgenerationwebui', type: textgen_types.FEATHERLESS, key: SECRET_KEYS.FEATHERLESS },
|
||||
{ api: 'textgenerationwebui', type: textgen_types.GENERIC, key: SECRET_KEYS.GENERIC },
|
||||
{ api: 'textgenerationwebui', type: textgen_types.HUGGINGFACE, key: SECRET_KEYS.HUGGINGFACE },
|
||||
{ api: 'textgenerationwebui', type: textgen_types.INFERMATICAI, key: SECRET_KEYS.INFERMATICAI },
|
||||
{ api: 'textgenerationwebui', type: textgen_types.KOBOLDCPP, key: SECRET_KEYS.KOBOLDCPP },
|
||||
{ api: 'textgenerationwebui', type: textgen_types.LLAMACPP, key: SECRET_KEYS.LLAMACPP },
|
||||
{ api: 'textgenerationwebui', type: textgen_types.MANCER, key: SECRET_KEYS.MANCER },
|
||||
{ api: 'textgenerationwebui', type: textgen_types.OOBA, key: SECRET_KEYS.OOBA },
|
||||
{ api: 'textgenerationwebui', type: textgen_types.OPENROUTER, key: SECRET_KEYS.OPENROUTER },
|
||||
{ api: 'textgenerationwebui', type: textgen_types.TABBY, key: SECRET_KEYS.TABBY },
|
||||
{ api: 'textgenerationwebui', type: textgen_types.TOGETHERAI, key: SECRET_KEYS.TOGETHERAI },
|
||||
{ api: 'textgenerationwebui', type: textgen_types.VLLM, key: SECRET_KEYS.VLLM },
|
||||
];
|
||||
|
||||
function addMissingLookupValues() {
|
||||
for (const key of Object.keys(textgen_types)) {
|
||||
if (Object.hasOwn(SECRET_KEYS, key) && !KEY_LOOKUP.some(entry => entry.key === SECRET_KEYS[key])) {
|
||||
KEY_LOOKUP.push({ api: 'textgenerationwebui', type: textgen_types[key], key: SECRET_KEYS[key] });
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(chat_completion_sources)) {
|
||||
if (Object.hasOwn(SECRET_KEYS, key) && !KEY_LOOKUP.some(entry => entry.key === SECRET_KEYS[key])) {
|
||||
KEY_LOOKUP.push({ api: 'openai', type: chat_completion_sources[key], key: SECRET_KEYS[key] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addEventHandlers() {
|
||||
eventSource.on(event_types.SECRET_WRITTEN, async (/** @type {string} */ key) => {
|
||||
if (MANAGER_STATE[key]) {
|
||||
const result = await migrateSecret(key, `key-${getKeyComment()}`);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
await refreshManagerState();
|
||||
toastr.success(t`Secret added to the rotation list.`, t`Secrets Manager`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the local state of the secrets manager.
|
||||
* @returns {Promise<void>} Promise that resolves when the state is refreshed
|
||||
*/
|
||||
async function refreshManagerState() {
|
||||
try {
|
||||
const response = await fetch('/api/secrets/manager/state', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch state: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
MANAGER_STATE = data;
|
||||
|
||||
// Refresh the secrets state to update the UI
|
||||
await readSecretState();
|
||||
} catch (error) {
|
||||
console.error('[Secrets Manager] Failed to refresh local state', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates a secret.
|
||||
* @param {string} key Secret key
|
||||
* @param {string|number} search Search value (index or comment)
|
||||
* @returns {Promise<boolean>} True if the secret was rotated successfully
|
||||
*/
|
||||
async function rotateSecret(key, search) {
|
||||
try {
|
||||
const response = await fetch('/api/secrets/manager/rotate', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ key, search }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
await refreshManagerState();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Secrets Manager] Failed to rotate secret', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a new secret to the rotation list.
|
||||
* @param {string} key Secret key
|
||||
* @param {string} value Secret value
|
||||
* @param {string} comment Secret comment
|
||||
* @returns {Promise<boolean>} True if the secret was appended successfully
|
||||
*/
|
||||
async function appendSecret(key, value, comment) {
|
||||
try {
|
||||
const response = await fetch('/api/secrets/manager/append', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ key, value, comment }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
await refreshManagerState();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Secrets Manager] Failed to append secret', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a secret from the rotation list.
|
||||
* @param {string} key Secret key
|
||||
* @param {number} index Index of the secret to remove
|
||||
* @returns {Promise<boolean>} True if the secret was removed successfully
|
||||
*/
|
||||
async function spliceSecret(key, index) {
|
||||
try {
|
||||
const response = await fetch('/api/secrets/manager/splice', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ key, index }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
await refreshManagerState();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Secrets Manager] Failed to splice secret', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a secret is managed by the secrets manager.
|
||||
* @param {string} key Secret key
|
||||
* @returns {Promise<number>} Index of the secret in the rotation list, or -1 if not managed
|
||||
*/
|
||||
async function probeSecret(key) {
|
||||
try {
|
||||
const response = await fetch('/api/secrets/manager/probe', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data?.index ?? -1;
|
||||
} catch (error) {
|
||||
console.error('[Secrets Manager] Failed to probe secret', error);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates a secret to the secrets manager.
|
||||
* @param {string} key Secret key
|
||||
* @param {string} comment Secret comment
|
||||
* @returns {Promise<boolean>} True if the secret was migrated successfully
|
||||
*/
|
||||
async function migrateSecret(key, comment) {
|
||||
try {
|
||||
const response = await fetch('/api/secrets/manager/migrate', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ key, comment }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 409) {
|
||||
throw new Error(t`Key is already managed by the Secrets Manager.`);
|
||||
}
|
||||
|
||||
throw new Error(`Server error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
await refreshManagerState();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Secrets Manager] Failed to migrate secret', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the key is managed by a secrets manager. If not, prompt to migrate.
|
||||
* @param {string} key Secret key
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function ensureKeyManaged(key) {
|
||||
let isKeyManaged = false;
|
||||
|
||||
if (secret_state[key] && Array.isArray(MANAGER_STATE[key]) && MANAGER_STATE[key].length > 0) {
|
||||
const result = await probeSecret(key);
|
||||
if (result >= 0) {
|
||||
isKeyManaged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isKeyManaged) {
|
||||
return;
|
||||
}
|
||||
|
||||
const comment = await Popup.show.input(
|
||||
t`Key is not managed`,
|
||||
t`Would you like to migrate the key to the Secrets Manager? If skipped, the currently saved value will be LOST FOREVER! Enter an optional comment below.`,
|
||||
`key-${getKeyComment()}`,
|
||||
{ okButton: 'Migrate', cancelButton: 'Skip' },
|
||||
);
|
||||
|
||||
if (comment === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await migrateSecret(key, comment);
|
||||
|
||||
if (!result) {
|
||||
toastr.warning(t`Failed to migrate secret. See DevTools for more details.`, t`Secrets Manager`);
|
||||
return;
|
||||
}
|
||||
|
||||
toastr.success(t`Key migrated successfully.`, t`Secrets Manager`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the click event for the clear key button.
|
||||
* @param {Event} event Event object
|
||||
*/
|
||||
function onKeyClearClick(event) {
|
||||
if (!(event.target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.target.dataset.key;
|
||||
if (MANAGER_STATE[key] && MANAGER_STATE[key].length > 0) {
|
||||
event.stopPropagation();
|
||||
showClearManagedKeyDialog(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a dialog to clear a managed key.
|
||||
* @param {string} key Secret key
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function showClearManagedKeyDialog(key) {
|
||||
const CLEAR_CANCEL = POPUP_RESULT.NEGATIVE;
|
||||
const CLEAR_CURRENT = POPUP_RESULT.AFFIRMATIVE;
|
||||
const CLEAR_ALL = 2;
|
||||
|
||||
const result = await Popup.show.text(
|
||||
t`Current key is managed by the Secrets Manager`,
|
||||
t`Would you like to clear just the current secret, or all secrets for this key?`,
|
||||
{
|
||||
okButton: t`Clear Current`,
|
||||
customButtons: [
|
||||
{
|
||||
text: t`Clear All`,
|
||||
result: CLEAR_ALL,
|
||||
appendAtEnd: true,
|
||||
},
|
||||
{
|
||||
text: t`Cancel`,
|
||||
result: CLEAR_CANCEL,
|
||||
appendAtEnd: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (result === CLEAR_CANCEL || result === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result === CLEAR_CURRENT) {
|
||||
await spliceSecret(key, MANAGER_STATE[key].findIndex(x => x.selected));
|
||||
await rotateSecret(key, '');
|
||||
}
|
||||
|
||||
if (result === CLEAR_ALL) {
|
||||
await writeSecret(key, '');
|
||||
while (MANAGER_STATE[key].length > 0) {
|
||||
await spliceSecret(key, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openSecretsManager() {
|
||||
alert('NOT IMPLEMENTED YET');
|
||||
}
|
||||
|
||||
function addSlashCommands() {
|
||||
const keyProvider = () => Object.values(SECRET_KEYS).map((key) => new SlashCommandEnumValue(key, null, null, enumIcons.key));
|
||||
const stateProvider = (/** @type {Executor} */ executor) => {
|
||||
const key = executor?.namedArgumentList?.find(arg => arg && arg.name === 'key')?.value ?? '';
|
||||
const secretToEnum = (/** @type {ManagedKeyState} */ secret, /** @type {number} */ index) => new SlashCommandEnumValue(String(index), secret.comment, null, enumIcons.secret);
|
||||
return key && typeof key === 'string' ? MANAGER_STATE[key].map(secretToEnum) : Object.values(MANAGER_STATE).flatMap(secrets => secrets.map(secretToEnum));
|
||||
};
|
||||
|
||||
const keyFromArgs = (/** @type {Args} */ args) => {
|
||||
let key = String(args?.key ?? '').trim().toLowerCase();
|
||||
|
||||
if (!key) {
|
||||
key = KEY_LOOKUP.find(e => e.api === main_api && (e.type === null || (e.api === 'openai' && e.type === oai_settings.chat_completion_source) || (e.api === 'textgenerationwebui' && e.type === textgenerationwebui_settings.type)))?.key;
|
||||
if (!key) {
|
||||
throw new Error(t`Secret key not provided or could not be inferred`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.values(SECRET_KEYS).includes(key)) {
|
||||
throw new Error(t`Unknown secret key`);
|
||||
}
|
||||
|
||||
return key;
|
||||
};
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'secret-add',
|
||||
aliases: ['secret-append', 'secret-insert', 'secret-save', 'secret-push'],
|
||||
helpString: t`Append a new secret to the rotation list.`,
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'key',
|
||||
description: t`Secret key`,
|
||||
typeList: ARGUMENT_TYPE.STRING,
|
||||
enumProvider: keyProvider,
|
||||
isRequired: true,
|
||||
acceptsMultiple: false,
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'comment',
|
||||
description: t`Comment for the secret`,
|
||||
typeList: ARGUMENT_TYPE.STRING,
|
||||
isRequired: false,
|
||||
acceptsMultiple: false,
|
||||
}),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: t`Secret value`,
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
acceptsMultiple: false,
|
||||
}),
|
||||
],
|
||||
callback: async (args, value) => {
|
||||
const key = keyFromArgs(args);
|
||||
const comment = String(args?.comment ?? '').trim();
|
||||
|
||||
if (!value) {
|
||||
throw new Error(t`Secret value not provided`);
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(t`Secret value must be a string`);
|
||||
}
|
||||
|
||||
await ensureKeyManaged(key);
|
||||
const result = await appendSecret(key, value, comment);
|
||||
|
||||
if (!result) {
|
||||
toastr.warning(t`Failed to append secret. See DevTools for more details.`, t`Secrets Manager`);
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'secret-remove',
|
||||
aliases: ['secret-delete', 'secret-splice'],
|
||||
helpString: t`Remove a secret from the rotation list.`,
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'key',
|
||||
description: t`Secret key`,
|
||||
typeList: ARGUMENT_TYPE.STRING,
|
||||
enumProvider: keyProvider,
|
||||
isRequired: true,
|
||||
acceptsMultiple: false,
|
||||
}),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: t`Secret index`,
|
||||
typeList: [ARGUMENT_TYPE.NUMBER],
|
||||
isRequired: true,
|
||||
acceptsMultiple: false,
|
||||
enumProvider: stateProvider,
|
||||
}),
|
||||
],
|
||||
callback: async (args, index) => {
|
||||
const key = keyFromArgs(args);
|
||||
|
||||
if (isNaN(Number(index))) {
|
||||
throw new Error(t`Invalid index`);
|
||||
}
|
||||
|
||||
const isSelected = MANAGER_STATE[key]?.[Number(index)]?.selected;
|
||||
const result = await spliceSecret(key, Number(index));
|
||||
|
||||
if (isSelected) {
|
||||
await rotateSecret(key, '');
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
toastr.warning(t`Failed to remove secret. See DevTools for more details.`, t`Secrets Manager`);
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'secret-current',
|
||||
helpString: t`Get the current index of the secret in rotation.`,
|
||||
returns: t`index or comment`,
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'key',
|
||||
description: t`Secret key`,
|
||||
typeList: ARGUMENT_TYPE.STRING,
|
||||
enumProvider: keyProvider,
|
||||
isRequired: true,
|
||||
acceptsMultiple: false,
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'comment',
|
||||
description: t`If true, return the comment instead of the index (if available)`,
|
||||
typeList: ARGUMENT_TYPE.BOOLEAN,
|
||||
isRequired: false,
|
||||
acceptsMultiple: false,
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'quiet',
|
||||
description: t`Suppress notifications`,
|
||||
typeList: ARGUMENT_TYPE.BOOLEAN,
|
||||
isRequired: false,
|
||||
acceptsMultiple: false,
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'return',
|
||||
description: 'The way you want the return value to be provided',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
defaultValue: 'pipe',
|
||||
enumList: slashCommandReturnHelper.enumList({ allowPipe: true, allowChat: false, allowPopup: true, allowTextVersion: false }),
|
||||
forceEnum: true,
|
||||
}),
|
||||
],
|
||||
callback: async (args) => {
|
||||
const key = keyFromArgs(args);
|
||||
const index = await probeSecret(key);
|
||||
|
||||
if (index < 0) {
|
||||
if (!args?.quiet) {
|
||||
toastr.warning(t`Key is not managed by the Secrets Manager.`, t`Secrets Manager`);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
let value = String(index);
|
||||
|
||||
if (isTrueBoolean(String(args?.comment))) {
|
||||
if (MANAGER_STATE?.[key]?.[index]?.comment) {
|
||||
value = MANAGER_STATE[key][index].comment;
|
||||
}
|
||||
}
|
||||
|
||||
const returnType = /** @type {any} */ (String(args?.return ?? '') || 'pipe');
|
||||
return await slashCommandReturnHelper.doReturn(returnType, value, { objectToStringFunc: String });
|
||||
},
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'secret-list',
|
||||
returns: t`{ [index]: comment }`,
|
||||
helpString: t`List all available secrets for a key. Does not include the actual secret values.`,
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'key',
|
||||
description: t`Secret key`,
|
||||
typeList: ARGUMENT_TYPE.STRING,
|
||||
enumProvider: keyProvider,
|
||||
isRequired: true,
|
||||
acceptsMultiple: false,
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'return',
|
||||
description: 'The way you want the return value to be provided',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
defaultValue: 'pipe',
|
||||
enumList: slashCommandReturnHelper.enumList({ allowPipe: true, allowChat: false, allowPopup: true, allowTextVersion: false }),
|
||||
forceEnum: true,
|
||||
}),
|
||||
],
|
||||
callback: async (args) => {
|
||||
const key = keyFromArgs(args);
|
||||
const state = MANAGER_STATE[key];
|
||||
|
||||
if (!Array.isArray(state)) {
|
||||
return JSON.stringify({});
|
||||
}
|
||||
|
||||
const list = Object.entries(state).reduce((acc, [index, secret]) => {
|
||||
acc[index] = secret.comment;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const returnType = /** @type {any} */ (String(args?.return ?? '') || 'pipe');
|
||||
return await slashCommandReturnHelper.doReturn(returnType, list, { objectToStringFunc: JSON.stringify });
|
||||
},
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'secret-migrate',
|
||||
helpString: t`Migrate a secret to the Secrets Manager. Optionally provide a comment.`,
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'key',
|
||||
description: t`Secret key`,
|
||||
typeList: ARGUMENT_TYPE.STRING,
|
||||
enumProvider: keyProvider,
|
||||
isRequired: true,
|
||||
acceptsMultiple: false,
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'comment',
|
||||
description: t`Comment for the secret`,
|
||||
typeList: ARGUMENT_TYPE.STRING,
|
||||
isRequired: false,
|
||||
acceptsMultiple: false,
|
||||
}),
|
||||
],
|
||||
callback: async (args) => {
|
||||
const key = keyFromArgs(args);
|
||||
const comment = String(args?.comment ?? '').trim();
|
||||
|
||||
const probe = await probeSecret(key);
|
||||
if (probe >= 0) {
|
||||
toastr.warning(t`Key is already managed by the Secrets Manager.`, t`Secrets Manager`);
|
||||
return '';
|
||||
}
|
||||
|
||||
const result = await migrateSecret(key, comment);
|
||||
|
||||
if (!result) {
|
||||
toastr.warning(t`Failed to migrate secret. See DevTools for more details.`, t`Secrets Manager`);
|
||||
return '';
|
||||
}
|
||||
|
||||
toastr.success(t`Key migrated successfully.`, t`Secrets Manager`);
|
||||
return '';
|
||||
},
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'secret-rotate',
|
||||
helpString: t`Rotate to a previously saved secret. Search by an index, comment, or leave empty to move to the next secret.`,
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'key',
|
||||
description: t`Secret key`,
|
||||
typeList: ARGUMENT_TYPE.STRING,
|
||||
enumProvider: keyProvider,
|
||||
isRequired: true,
|
||||
acceptsMultiple: false,
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'quiet',
|
||||
description: t`Suppress notifications`,
|
||||
typeList: ARGUMENT_TYPE.BOOLEAN,
|
||||
isRequired: false,
|
||||
acceptsMultiple: false,
|
||||
}),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: t`Search string (index or comment)`,
|
||||
typeList: [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.NUMBER],
|
||||
isRequired: true,
|
||||
acceptsMultiple: false,
|
||||
forceEnum: true,
|
||||
enumProvider: stateProvider,
|
||||
}),
|
||||
],
|
||||
callback: async (args, value) => {
|
||||
const key = keyFromArgs(args);
|
||||
await ensureKeyManaged(key);
|
||||
|
||||
const search = isNaN(parseInt(String(value))) ? String(value) : Number(value);
|
||||
const result = await rotateSecret(key, search);
|
||||
|
||||
if (!result) {
|
||||
toastr.warning(t`Failed to rotate secret. See DevTools for more details.`, t`Secrets Manager`);
|
||||
}
|
||||
|
||||
if (!args?.quiet) {
|
||||
toastr.success(t`Secret rotated successfully.`, t`Secrets Manager`);
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
(async function initExtension() {
|
||||
const parentBlock = document.getElementById('main-API-selector-block');
|
||||
if (!parentBlock) {
|
||||
console.error('[Secrets Manager] Parent block not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const button = document.createElement('div');
|
||||
button.id = 'secrets-manager-button';
|
||||
button.classList.add('menu_button', 'menu_button_icon');
|
||||
button.addEventListener('click', openSecretsManager);
|
||||
const icon = document.createElement('i');
|
||||
icon.classList.add('fa-solid', 'fa-key', 'fa-sm');
|
||||
const label = document.createElement('span');
|
||||
label.textContent = t`Secrets`;
|
||||
label.classList.add('alignItemsBaseline');
|
||||
button.appendChild(icon);
|
||||
button.appendChild(label);
|
||||
parentBlock.appendChild(button);
|
||||
|
||||
addSlashCommands();
|
||||
addEventHandlers();
|
||||
addMissingLookupValues();
|
||||
await refreshManagerState();
|
||||
|
||||
document.querySelectorAll('.clear-api-key').forEach((element) => {
|
||||
element.addEventListener('click', onKeyClearClick, { capture: true });
|
||||
});
|
||||
})();
|
11
public/scripts/extensions/secrets-manager/manifest.json
Normal file
11
public/scripts/extensions/secrets-manager/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"display_name": "Secrets Manager",
|
||||
"loading_order": 10,
|
||||
"requires": [],
|
||||
"optional": [],
|
||||
"js": "index.js",
|
||||
"css": "style.css",
|
||||
"author": "Cohee1207",
|
||||
"version": "1.0.0",
|
||||
"homePage": "https://github.com/SillyTavern/SillyTavern"
|
||||
}
|
0
public/scripts/extensions/secrets-manager/style.css
Normal file
0
public/scripts/extensions/secrets-manager/style.css
Normal file
@@ -1,5 +1,5 @@
|
||||
import { DOMPurify } from '../lib.js';
|
||||
import { callPopup, getRequestHeaders } from '../script.js';
|
||||
import { callPopup, event_types, eventSource, getRequestHeaders } from '../script.js';
|
||||
|
||||
export const SECRET_KEYS = {
|
||||
HORDE: 'api_key_horde',
|
||||
@@ -125,6 +125,11 @@ async function viewSecrets() {
|
||||
|
||||
export let secret_state = {};
|
||||
|
||||
/**
|
||||
* Write a secret to the backend.
|
||||
* @param {string} key Secret key
|
||||
* @param {string} value Secret value
|
||||
*/
|
||||
export async function writeSecret(key, value) {
|
||||
try {
|
||||
const response = await fetch('/api/secrets/write', {
|
||||
@@ -134,12 +139,9 @@ export async function writeSecret(key, value) {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
if (text == 'ok') {
|
||||
secret_state[key] = !!value;
|
||||
updateSecretDisplay();
|
||||
}
|
||||
secret_state[key] = !!value;
|
||||
updateSecretDisplay();
|
||||
await eventSource.emit(event_types.SECRET_WRITTEN, key);
|
||||
}
|
||||
} catch {
|
||||
console.error('Could not write secret value: ', key);
|
||||
|
@@ -37,6 +37,8 @@ export const enumIcons = {
|
||||
voice: '🎤',
|
||||
server: '🖥️',
|
||||
popup: '🗔',
|
||||
key: '🔑',
|
||||
secret: '🔒',
|
||||
|
||||
true: '✔️',
|
||||
false: '❌',
|
||||
|
@@ -2693,6 +2693,13 @@ select option:not(:checked) {
|
||||
|
||||
/*#######################################################################*/
|
||||
|
||||
#main-API-selector-block {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 5px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
#rm_api_block {
|
||||
display: none;
|
||||
overflow-y: auto;
|
||||
|
@@ -6,6 +6,8 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic';
|
||||
import { getConfigValue } from '../util.js';
|
||||
import { jsonParser } from '../express-common.js';
|
||||
|
||||
const allowKeysExposure = !!getConfigValue('allowKeysExposure', false);
|
||||
|
||||
export const SECRETS_FILE = 'secrets.json';
|
||||
export const SECRET_KEYS = {
|
||||
HORDE: 'api_key_horde',
|
||||
@@ -54,6 +56,8 @@ export const SECRET_KEYS = {
|
||||
DEEPSEEK: 'api_key_deepseek',
|
||||
};
|
||||
|
||||
const INITIAL_STATE = /** @type {SecretState} */ (Object.freeze({ managed: {} }));
|
||||
|
||||
// These are the keys that are safe to expose, even if allowKeysExposure is false
|
||||
const EXPORTABLE_KEYS = [
|
||||
SECRET_KEYS.LIBRE_URL,
|
||||
@@ -62,6 +66,41 @@ const EXPORTABLE_KEYS = [
|
||||
SECRET_KEYS.DEEPLX_URL,
|
||||
];
|
||||
|
||||
/**
|
||||
* @typedef {object} ManagedKey
|
||||
* @property {string} comment Key comment
|
||||
* @property {string} value Key value
|
||||
* @typedef {Record<string, string> & { managed: Record<string, ManagedKey[]> }} SecretState
|
||||
* @typedef {Omit<ManagedKey & { selected: boolean }, 'value'>} ManagedKeyState
|
||||
* @typedef {Record<string, ManagedKeyState[]>} SecretManagerState
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reads the secret state from the secrets file.
|
||||
* @param {import('../users.js').UserDirectoryList} directories User directories
|
||||
* @returns {SecretState} Secret state
|
||||
*/
|
||||
function getSecretState(directories) {
|
||||
const filePath = path.join(directories.root, SECRETS_FILE);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return structuredClone(INITIAL_STATE);
|
||||
}
|
||||
|
||||
const fileContents = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(fileContents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the secret state to the secrets file.
|
||||
* @param {import('../users.js').UserDirectoryList} directories User directories
|
||||
* @param {SecretState} state New secret state
|
||||
*/
|
||||
function updateSecretState(directories, state) {
|
||||
const filePath = path.join(directories.root, SECRETS_FILE);
|
||||
writeFileAtomicSync(filePath, JSON.stringify(state, null, 4), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a secret to the secrets file
|
||||
* @param {import('../users.js').UserDirectoryList} directories User directories
|
||||
@@ -69,36 +108,20 @@ const EXPORTABLE_KEYS = [
|
||||
* @param {string} value Secret value
|
||||
*/
|
||||
export function writeSecret(directories, key, value) {
|
||||
const filePath = path.join(directories.root, SECRETS_FILE);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const emptyFile = JSON.stringify({});
|
||||
writeFileAtomicSync(filePath, emptyFile, 'utf-8');
|
||||
}
|
||||
|
||||
const fileContents = fs.readFileSync(filePath, 'utf-8');
|
||||
const secrets = JSON.parse(fileContents);
|
||||
const secrets = getSecretState(directories);
|
||||
secrets[key] = value;
|
||||
writeFileAtomicSync(filePath, JSON.stringify(secrets, null, 4), 'utf-8');
|
||||
updateSecretState(directories, secrets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a secret from the secrets file
|
||||
* @param {import('../users.js').UserDirectoryList} directories User directories
|
||||
* @param {string} key Secret key
|
||||
* @returns
|
||||
*/
|
||||
export function deleteSecret(directories, key) {
|
||||
const filePath = path.join(directories.root, SECRETS_FILE);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileContents = fs.readFileSync(filePath, 'utf-8');
|
||||
const secrets = JSON.parse(fileContents);
|
||||
const secrets = getSecretState(directories);
|
||||
delete secrets[key];
|
||||
writeFileAtomicSync(filePath, JSON.stringify(secrets, null, 4), 'utf-8');
|
||||
updateSecretState(directories, secrets);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,15 +131,125 @@ export function deleteSecret(directories, key) {
|
||||
* @returns {string} Secret value
|
||||
*/
|
||||
export function readSecret(directories, key) {
|
||||
const filePath = path.join(directories.root, SECRETS_FILE);
|
||||
const secrets = getSecretState(directories);
|
||||
return secrets[key];
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return '';
|
||||
/**
|
||||
* Rotates a secret in the secrets file.
|
||||
* @param {import('../users.js').UserDirectoryList} directories User directories
|
||||
* @param {string} key Key to rotate
|
||||
* @param {string|number} [searchValue] Search value (comment or index)
|
||||
*/
|
||||
export function rotateManagedSecret(directories, key, searchValue) {
|
||||
const secrets = getSecretState(directories);
|
||||
|
||||
if (!secrets.managed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileContents = fs.readFileSync(filePath, 'utf-8');
|
||||
const secrets = JSON.parse(fileContents);
|
||||
return secrets[key];
|
||||
if (!Array.isArray(secrets.managed[key]) || secrets.managed[key].length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let keyData = null;
|
||||
const managed = secrets.managed[key];
|
||||
if (typeof searchValue === 'number' && searchValue >= 0 && searchValue < managed.length) {
|
||||
keyData = managed[searchValue];
|
||||
}
|
||||
if (typeof searchValue === 'string' && searchValue.trim().length > 0) {
|
||||
keyData = managed.find(key => String(key.comment).trim().toLowerCase() === searchValue.trim().toLowerCase());
|
||||
}
|
||||
if (!keyData) {
|
||||
const currentSecret = readSecret(directories, key);
|
||||
const currentIndex = managed.findIndex((key) => key.value === currentSecret);
|
||||
keyData = managed[currentIndex + 1] || managed[0];
|
||||
}
|
||||
|
||||
writeSecret(directories, key, keyData.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a managed key to the secrets file.
|
||||
* @param {import('../users.js').UserDirectoryList} directories User directories
|
||||
* @param {string} key Key identifier
|
||||
* @param {string} comment Comment for the key
|
||||
* @param {string} value Value for the key
|
||||
*/
|
||||
export function appendManagedKey(directories, key, comment, value) {
|
||||
const secrets = getSecretState(directories);
|
||||
if (!secrets.managed) {
|
||||
secrets.managed = {};
|
||||
}
|
||||
if (!secrets.managed[key]) {
|
||||
secrets.managed[key] = [];
|
||||
}
|
||||
secrets.managed[key].push({ comment, value });
|
||||
updateSecretState(directories, secrets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a managed key from the secrets.
|
||||
* @param {import('../users.js').UserDirectoryList} directories User directories
|
||||
* @param {string} key Key identifier
|
||||
* @param {number} index Index of the key to remove
|
||||
* @returns
|
||||
*/
|
||||
export function spliceManagedKey(directories, key, index) {
|
||||
const secrets = getSecretState(directories);
|
||||
|
||||
if (!secrets.managed || !Array.isArray(secrets.managed[key])) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (index < 0 || index >= secrets.managed[key].length) {
|
||||
return;
|
||||
}
|
||||
|
||||
secrets.managed[key].splice(index, 1);
|
||||
updateSecretState(directories, secrets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the saved key value is managed by the secret manager.
|
||||
* @param {import('../users.js').UserDirectoryList} directories
|
||||
* @param {string} key Key identifier
|
||||
* @returns {{result: boolean, index: number}} Probe result
|
||||
*/
|
||||
function probeManagedKey(directories, key) {
|
||||
const secrets = getSecretState(directories);
|
||||
|
||||
if (!secrets.managed || !Array.isArray(secrets.managed[key])) {
|
||||
return { result: false, index: -1 };
|
||||
}
|
||||
|
||||
const currentSecret = readSecret(directories, key);
|
||||
const index = secrets.managed[key].findIndex((key) => key.value === currentSecret);
|
||||
return { result: index !== -1, index };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the managed secrets state.
|
||||
* @param {import('../users.js').UserDirectoryList} directories
|
||||
* @returns {SecretManagerState} Secret state
|
||||
*/
|
||||
function readManagedSecretsState(directories) {
|
||||
const secrets = getSecretState(directories);
|
||||
const state = /** @type {SecretManagerState} */ ({});
|
||||
|
||||
if (!secrets.managed) {
|
||||
return state;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(secrets.managed)) {
|
||||
state[key] = [];
|
||||
for (const secret of secrets.managed[key]) {
|
||||
const selected = secrets[key] === secret.value;
|
||||
state[key].push({ comment: secret.comment, selected: selected });
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,6 +269,9 @@ export function readSecretState(directories) {
|
||||
const state = {};
|
||||
|
||||
for (const key of Object.values(SECRET_KEYS)) {
|
||||
if (key === 'managed') {
|
||||
continue;
|
||||
}
|
||||
state[key] = !!secrets[key]; // convert to boolean
|
||||
}
|
||||
|
||||
@@ -167,7 +303,7 @@ router.post('/write', jsonParser, (request, response) => {
|
||||
const value = request.body.value;
|
||||
|
||||
writeSecret(request.user.directories, key, value);
|
||||
return response.send('ok');
|
||||
return response.sendStatus(204);
|
||||
});
|
||||
|
||||
router.post('/read', jsonParser, (request, response) => {
|
||||
@@ -203,7 +339,6 @@ router.post('/view', jsonParser, async (request, response) => {
|
||||
});
|
||||
|
||||
router.post('/find', jsonParser, (request, response) => {
|
||||
const allowKeysExposure = getConfigValue('allowKeysExposure', false);
|
||||
const key = request.body.key;
|
||||
|
||||
if (!allowKeysExposure && !EXPORTABLE_KEYS.includes(key)) {
|
||||
@@ -224,3 +359,56 @@ router.post('/find', jsonParser, (request, response) => {
|
||||
return response.sendStatus(500);
|
||||
}
|
||||
});
|
||||
|
||||
// Secret Manager handlers
|
||||
const manager = express.Router();
|
||||
router.use('/manager', manager);
|
||||
|
||||
manager.post('/state', jsonParser, (request, response) => {
|
||||
const state = readManagedSecretsState(request.user.directories);
|
||||
return response.send(state);
|
||||
});
|
||||
|
||||
manager.post('/rotate', jsonParser, (request, response) => {
|
||||
const key = request.body.key;
|
||||
const searchValue = request.body.search;
|
||||
|
||||
rotateManagedSecret(request.user.directories, key, searchValue);
|
||||
return response.sendStatus(204);
|
||||
});
|
||||
|
||||
manager.post('/append', jsonParser, (request, response) => {
|
||||
const key = request.body.key;
|
||||
const comment = request.body.comment;
|
||||
const value = request.body.value;
|
||||
|
||||
appendManagedKey(request.user.directories, key, comment, value);
|
||||
return response.sendStatus(204);
|
||||
});
|
||||
|
||||
manager.post('/splice', jsonParser, (request, response) => {
|
||||
const key = request.body.key;
|
||||
const index = request.body.index;
|
||||
|
||||
spliceManagedKey(request.user.directories, key, index);
|
||||
return response.sendStatus(204);
|
||||
});
|
||||
|
||||
manager.post('/probe', jsonParser, (request, response) => {
|
||||
const key = request.body.key;
|
||||
const result = probeManagedKey(request.user.directories, key);
|
||||
return response.send(result);
|
||||
});
|
||||
|
||||
manager.post('/migrate', jsonParser, (request, response) => {
|
||||
const key = request.body.key;
|
||||
const comment = request.body.comment;
|
||||
|
||||
const probeResult = probeManagedKey(request.user.directories, key);
|
||||
if (probeResult.result) {
|
||||
return response.sendStatus(409);
|
||||
}
|
||||
const currentSecret = readSecret(request.user.directories, key);
|
||||
appendManagedKey(request.user.directories, key, comment, currentSecret);
|
||||
return response.sendStatus(204);
|
||||
});
|
||||
|
Reference in New Issue
Block a user