@alilis/k-hat 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 +21 -0
- package/README.md +229 -0
- package/dist/admin.js +213 -0
- package/dist/cli.js +700 -0
- package/dist/config.js +84 -0
- package/dist/doctor.js +123 -0
- package/dist/dpapi.js +34 -0
- package/dist/key-protector.js +94 -0
- package/dist/logger.js +67 -0
- package/dist/portable-vault.js +146 -0
- package/dist/router.js +10 -0
- package/dist/selector.js +23 -0
- package/dist/server.js +228 -0
- package/dist/store.js +240 -0
- package/dist/supervisor.js +98 -0
- package/dist/tui-client.js +87 -0
- package/dist/tui-main.js +8 -0
- package/dist/tui-types.js +1 -0
- package/dist/tui.js +286 -0
- package/dist/types.js +1 -0
- package/dist/vault.js +96 -0
- package/dist/web-ui.js +323 -0
- package/package.json +52 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFile, writeFile, rename } from 'node:fs/promises';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
export const defaultTimeouts = { headerMs: 60_000, streamIdleMs: 300_000 };
|
|
4
|
+
export const defaultConfig = {
|
|
5
|
+
version: 1,
|
|
6
|
+
bind: '127.0.0.1',
|
|
7
|
+
port: 8787,
|
|
8
|
+
requestBodyLimitMB: 64,
|
|
9
|
+
providers: [],
|
|
10
|
+
routes: []
|
|
11
|
+
};
|
|
12
|
+
export async function readJsonFile(path) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error?.code === 'ENOENT')
|
|
18
|
+
return undefined;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export async function loadConfig(path) {
|
|
23
|
+
const raw = await readJsonFile(path);
|
|
24
|
+
return raw === undefined ? structuredClone(defaultConfig) : validateConfig(raw);
|
|
25
|
+
}
|
|
26
|
+
export async function saveJsonAtomic(path, value) {
|
|
27
|
+
const temp = `${path}.tmp-${process.pid}-${randomBytes(8).toString('hex')}`;
|
|
28
|
+
await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
29
|
+
await rename(temp, path);
|
|
30
|
+
}
|
|
31
|
+
function isLoopbackHost(hostname) {
|
|
32
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
|
33
|
+
}
|
|
34
|
+
export function validateConfig(config) {
|
|
35
|
+
if (!config || config.version !== 1 || !Array.isArray(config.providers) || !Array.isArray(config.routes)) {
|
|
36
|
+
throw new Error('Invalid configuration: expected version 1 with providers and routes');
|
|
37
|
+
}
|
|
38
|
+
if (config.timeouts !== undefined) {
|
|
39
|
+
for (const field of ['headerMs', 'streamIdleMs']) {
|
|
40
|
+
const value = config.timeouts[field];
|
|
41
|
+
if (!Number.isInteger(value) || value < 1)
|
|
42
|
+
throw new Error(`Invalid timeouts.${field}: expected a positive integer`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const provider of config.providers) {
|
|
46
|
+
if (!provider.id || !provider.baseUrl || (provider.protocol !== 'openai' && provider.protocol !== 'anthropic') || !Array.isArray(provider.keys))
|
|
47
|
+
throw new Error(`Invalid provider: ${provider.id}`);
|
|
48
|
+
let url;
|
|
49
|
+
try {
|
|
50
|
+
url = new URL(provider.baseUrl);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new Error(`Provider ${provider.id}: baseUrl is not a valid URL`);
|
|
54
|
+
}
|
|
55
|
+
if (url.protocol !== 'https:' && !isLoopbackHost(url.hostname))
|
|
56
|
+
throw new Error(`Provider ${provider.id}: baseUrl must use https (plain http is only allowed on loopback hosts)`);
|
|
57
|
+
if (url.pathname !== '/')
|
|
58
|
+
throw new Error(`Provider ${provider.id}: baseUrl must not contain a path (strip trailing /v1 etc.)`);
|
|
59
|
+
for (const key of provider.keys) {
|
|
60
|
+
if (!key.id || !Number.isInteger(key.weight) || key.weight < 1)
|
|
61
|
+
throw new Error(`Invalid key in provider ${provider.id}`);
|
|
62
|
+
if (key.vaultRef !== `${provider.id}/${key.id}`)
|
|
63
|
+
throw new Error(`Invalid key ${provider.id}/${key.id}: vaultRef must match provider and key id`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const route of config.routes) {
|
|
67
|
+
if (!route.model || !route.provider)
|
|
68
|
+
throw new Error('Invalid route: model and provider are required');
|
|
69
|
+
if (!config.providers.some((item) => item.id === route.provider))
|
|
70
|
+
throw new Error(`Invalid route ${route.model}: unknown provider ${route.provider}`);
|
|
71
|
+
}
|
|
72
|
+
return config;
|
|
73
|
+
}
|
|
74
|
+
export async function loadState(path) {
|
|
75
|
+
try {
|
|
76
|
+
const state = JSON.parse(await readFile(path, 'utf8'));
|
|
77
|
+
return { keys: state.keys ?? {}, counters: state.counters ?? {} };
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error?.code === 'ENOENT')
|
|
81
|
+
return { keys: {}, counters: {} };
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const TOOL_PATHS = {
|
|
5
|
+
Codex: [join(homedir(), '.codex', 'config.toml'), join(homedir(), '.codex', 'config.json')],
|
|
6
|
+
OpenCode: [join(homedir(), '.config', 'opencode', 'opencode.jsonc'), join(homedir(), '.config', 'opencode', 'config.json'), join(homedir(), '.opencode', 'config.json')],
|
|
7
|
+
ZCode: [join(homedir(), '.zcode', 'v2', 'config.json'), join(homedir(), '.zcode', 'config.yaml'), join(homedir(), '.zcode', 'config.yml')],
|
|
8
|
+
Cursor: [join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'Cursor', 'User', 'settings.json'), join(homedir(), '.cursor', 'config.json')]
|
|
9
|
+
};
|
|
10
|
+
function stripJsonComments(text) { return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1'); }
|
|
11
|
+
function scalar(value) {
|
|
12
|
+
const v = value.trim().replace(/^['"]|['"]$/g, '');
|
|
13
|
+
if (v === 'true')
|
|
14
|
+
return true;
|
|
15
|
+
if (v === 'false')
|
|
16
|
+
return false;
|
|
17
|
+
if (v === 'null')
|
|
18
|
+
return null;
|
|
19
|
+
const n = Number(v);
|
|
20
|
+
return v !== '' && Number.isFinite(n) ? n : v;
|
|
21
|
+
}
|
|
22
|
+
/** Deliberately small, dependency-free TOML reader for the common provider tables. */
|
|
23
|
+
export function parseToml(text) {
|
|
24
|
+
const result = {};
|
|
25
|
+
let section = result;
|
|
26
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
27
|
+
const line = raw.replace(/\s+#.*$/, '').trim();
|
|
28
|
+
if (!line || line.startsWith('#'))
|
|
29
|
+
continue;
|
|
30
|
+
const header = line.match(/^\[([^\]]+)\]$/);
|
|
31
|
+
if (header) {
|
|
32
|
+
section = result;
|
|
33
|
+
for (const part of header[1].split('.'))
|
|
34
|
+
section = (section[part] ??= {});
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const eq = line.indexOf('=');
|
|
38
|
+
if (eq < 1)
|
|
39
|
+
continue;
|
|
40
|
+
section[line.slice(0, eq).trim()] = scalar(line.slice(eq + 1));
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
/** Reads flat/nested YAML values sufficiently to inspect endpoint settings without a YAML dependency. */
|
|
45
|
+
export function parseYaml(text) {
|
|
46
|
+
const root = {};
|
|
47
|
+
const stack = [{ indent: -1, value: root }];
|
|
48
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
49
|
+
const line = raw.replace(/\s+#.*$/, '');
|
|
50
|
+
if (!line.trim() || line.trim().startsWith('#'))
|
|
51
|
+
continue;
|
|
52
|
+
const m = line.match(/^(\s*)([^:]+):\s*(.*)$/);
|
|
53
|
+
if (!m)
|
|
54
|
+
continue;
|
|
55
|
+
const indent = m[1].length;
|
|
56
|
+
while (stack.length > 1 && indent <= stack.at(-1).indent)
|
|
57
|
+
stack.pop();
|
|
58
|
+
const parent = stack.at(-1).value;
|
|
59
|
+
const key = m[2].trim().replace(/^['"]|['"]$/g, '');
|
|
60
|
+
if (m[3].trim())
|
|
61
|
+
parent[key] = scalar(m[3]);
|
|
62
|
+
else {
|
|
63
|
+
const child = {};
|
|
64
|
+
parent[key] = child;
|
|
65
|
+
stack.push({ indent, value: child });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return root;
|
|
69
|
+
}
|
|
70
|
+
export function parseDoctorConfig(text, format) {
|
|
71
|
+
try {
|
|
72
|
+
if (format === 'toml')
|
|
73
|
+
return parseToml(text);
|
|
74
|
+
if (format === 'yaml')
|
|
75
|
+
return parseYaml(text);
|
|
76
|
+
return JSON.parse(stripJsonComments(text));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function findValues(value, keys, out, path = '') {
|
|
83
|
+
if (!value || typeof value !== 'object')
|
|
84
|
+
return;
|
|
85
|
+
for (const [key, child] of Object.entries(value)) {
|
|
86
|
+
const next = path ? `${path}.${key}` : key;
|
|
87
|
+
if (keys.has(key.toLowerCase()) && typeof child === 'string')
|
|
88
|
+
out.push({ path: next, value: child });
|
|
89
|
+
findValues(child, keys, out, next);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function formatFor(path) { const lower = path.toLowerCase(); return lower.endsWith('.toml') ? 'toml' : lower.endsWith('.yaml') || lower.endsWith('.yml') ? 'yaml' : 'json'; }
|
|
93
|
+
export function inspectDoctorConfig(tool, path, text, proxyBase = 'http://127.0.0.1:8787/v1') {
|
|
94
|
+
const format = formatFor(path);
|
|
95
|
+
const data = parseDoctorConfig(text, format);
|
|
96
|
+
const found = [];
|
|
97
|
+
findValues(data, new Set(['base_url', 'baseurl', 'endpoint', 'apiurl', 'api_url']), found);
|
|
98
|
+
const changes = found.filter(({ value }) => !value.includes('127.0.0.1:8787')).map(({ path: key, value }) => ({ path: key, current: value, suggested: proxyBase }));
|
|
99
|
+
if (!changes.length) {
|
|
100
|
+
const examplePath = tool === 'Codex' ? 'model_providers.<name>.base_url' : 'provider.<name>.options.baseURL';
|
|
101
|
+
changes.push({ path: examplePath, suggested: `${proxyBase}(新增该项)` });
|
|
102
|
+
}
|
|
103
|
+
const warnings = tool === 'Cursor' ? ['Cursor 仅部分功能支持自定义 OpenAI 端点;Tab 等功能可能仍走官方服务。'] : ['建议将入口令牌写入工具支持的凭据存储,不要把明文 token 提交到配置文件。'];
|
|
104
|
+
return { tool, path, format, detected: true, summary: changes.some((x) => x.current !== undefined) ? '检测到可迁移的端点配置' : '检测到配置文件,可按建议接入 khat', changes, warnings };
|
|
105
|
+
}
|
|
106
|
+
export async function runDoctor(options = {}) {
|
|
107
|
+
const read = options.readFile ?? ((path) => readFile(path, 'utf8'));
|
|
108
|
+
const output = [];
|
|
109
|
+
for (const tool of Object.keys(options.paths ?? TOOL_PATHS))
|
|
110
|
+
for (const path of options.paths?.[tool] ?? TOOL_PATHS[tool]) {
|
|
111
|
+
try {
|
|
112
|
+
output.push(inspectDoctorConfig(tool, path, await read(path), options.proxyBase));
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error?.code !== 'ENOENT')
|
|
117
|
+
output.push({ tool, path, format: formatFor(path), detected: true, summary: '无法读取配置文件', changes: [], warnings: [error?.message ?? '读取失败'] });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return output;
|
|
121
|
+
}
|
|
122
|
+
export function formatDoctorSuggestion(item) { return [` ${item.tool}: detected (${item.path})`, ...item.changes.map((x) => ` ${x.path}: ${x.current ? `${x.current} -> ` : ''}${x.suggested}`), ...item.warnings.map((x) => ` warning: ${x}`)]; }
|
|
123
|
+
export { TOOL_PATHS };
|
package/dist/dpapi.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
// Extra entropy bound into the DPAPI blob so a bare ProtectedData::Unprotect call
|
|
3
|
+
// by other local code cannot reuse vault master key material.
|
|
4
|
+
const ENTROPY = 'khat-vault-v1';
|
|
5
|
+
function powershell(script, input) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { windowsHide: true });
|
|
8
|
+
let stdout = '';
|
|
9
|
+
let stderr = '';
|
|
10
|
+
child.stdout.on('data', (chunk) => (stdout += chunk));
|
|
11
|
+
child.stderr.on('data', (chunk) => (stderr += chunk));
|
|
12
|
+
child.on('error', reject);
|
|
13
|
+
child.on('close', (code) => (code === 0 ? resolve(stdout.trim()) : reject(new Error(`powershell exited with code ${code}: ${stderr.trim()}`))));
|
|
14
|
+
// Secrets travel via stdin only; they never appear on the command line.
|
|
15
|
+
child.stdin.end(input);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async function dpapi(data, direction) {
|
|
19
|
+
if (process.platform !== 'win32') {
|
|
20
|
+
throw new Error('Windows DPAPI protector cannot run on this platform; use the platform key-protector factory');
|
|
21
|
+
}
|
|
22
|
+
const script = [
|
|
23
|
+
'Add-Type -AssemblyName System.Security',
|
|
24
|
+
"$in=[Convert]::FromBase64String([Console]::In.ReadToEnd().Trim())",
|
|
25
|
+
"$e=[Text.Encoding]::UTF8.GetBytes('" + ENTROPY + "')",
|
|
26
|
+
`$out=[Security.Cryptography.ProtectedData]::${direction === 'protect' ? 'Protect' : 'Unprotect'}($in,$e,[Security.Cryptography.DataProtectionScope]::CurrentUser)`,
|
|
27
|
+
'[Convert]::ToBase64String($out)'
|
|
28
|
+
].join('\n');
|
|
29
|
+
return Buffer.from(await powershell(script, data.toString('base64')), 'base64');
|
|
30
|
+
}
|
|
31
|
+
export const dpapiProtector = {
|
|
32
|
+
protect: (plain) => dpapi(plain, 'protect'),
|
|
33
|
+
unprotect: (wrapped) => dpapi(wrapped, 'unprotect')
|
|
34
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
3
|
+
import { dpapiProtector } from './dpapi.js';
|
|
4
|
+
const SERVICE = 'khat-vault';
|
|
5
|
+
const ACCOUNT = 'khat';
|
|
6
|
+
function run(command, args, input = '') {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
9
|
+
let out = '', err = '';
|
|
10
|
+
child.stdout.on('data', (chunk) => out += chunk);
|
|
11
|
+
child.stderr.on('data', (chunk) => err += chunk);
|
|
12
|
+
child.once('error', (error) => reject(new Error(`${command} is unavailable: ${error.message}`)));
|
|
13
|
+
child.once('close', (code) => code === 0 ? resolve(out) : reject(new Error(`${command} failed (exit ${code}): ${err.trim() || out.trim()}`)));
|
|
14
|
+
child.stdin.end(input);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function commandProtector(platform, runner = run) {
|
|
18
|
+
let cached;
|
|
19
|
+
async function load() {
|
|
20
|
+
if (cached)
|
|
21
|
+
return cached;
|
|
22
|
+
try {
|
|
23
|
+
const output = platform === 'darwin'
|
|
24
|
+
? await runner('security', ['find-generic-password', '-a', ACCOUNT, '-s', SERVICE, '-w'])
|
|
25
|
+
: await runner('secret-tool', ['lookup', 'service', SERVICE, 'account', ACCOUNT]);
|
|
26
|
+
const value = Buffer.from(output.trim(), 'base64');
|
|
27
|
+
if (value.length !== 32)
|
|
28
|
+
throw new Error('stored protector key has an invalid length');
|
|
29
|
+
cached = value;
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error?.message?.includes('unavailable'))
|
|
34
|
+
throw new Error(`${platform === 'darwin' ? 'macOS Keychain (security)' : 'Linux Secret Service (secret-tool)'} is unavailable; install/enable it before using khat (no plaintext fallback)`);
|
|
35
|
+
throw new Error(`could not read Khat protector key from ${platform === 'darwin' ? 'macOS Keychain' : 'Linux Secret Service'}: ${error.message}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function ensure() {
|
|
39
|
+
try {
|
|
40
|
+
return await load();
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (!error.message.startsWith('could not read'))
|
|
44
|
+
throw error;
|
|
45
|
+
const key = randomBytes(32).toString('base64');
|
|
46
|
+
try {
|
|
47
|
+
if (platform === 'darwin')
|
|
48
|
+
await runner('security', ['add-generic-password', '-a', ACCOUNT, '-s', SERVICE, '-w', key, '-U']);
|
|
49
|
+
else
|
|
50
|
+
await runner('secret-tool', ['store', '--label=Khat vault protector', 'service', SERVICE, 'account', ACCOUNT], key);
|
|
51
|
+
cached = Buffer.from(key, 'base64');
|
|
52
|
+
return cached;
|
|
53
|
+
}
|
|
54
|
+
catch (saveError) {
|
|
55
|
+
throw new Error(`could not create Khat protector key in ${platform === 'darwin' ? 'macOS Keychain' : 'Linux Secret Service'}: ${saveError.message} (no plaintext fallback)`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
protect: async (plain) => {
|
|
61
|
+
const key = await ensure();
|
|
62
|
+
const nonce = randomBytes(12);
|
|
63
|
+
const cipher = createCipheriv('aes-256-gcm', key, nonce);
|
|
64
|
+
const ciphertext = Buffer.concat([cipher.update(plain), cipher.final()]);
|
|
65
|
+
return Buffer.concat([nonce, cipher.getAuthTag(), ciphertext]);
|
|
66
|
+
},
|
|
67
|
+
unprotect: async (wrapped) => {
|
|
68
|
+
const key = await load();
|
|
69
|
+
if (wrapped.length < 28)
|
|
70
|
+
throw new Error('could not unwrap vault master key: invalid protected data');
|
|
71
|
+
try {
|
|
72
|
+
const decipher = createDecipheriv('aes-256-gcm', key, wrapped.subarray(0, 12));
|
|
73
|
+
decipher.setAuthTag(wrapped.subarray(12, 28));
|
|
74
|
+
return Buffer.concat([decipher.update(wrapped.subarray(28)), decipher.final()]);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new Error('could not unwrap vault master key: protector key mismatch or corrupted data');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function createKeyProtector(platform = process.platform) {
|
|
83
|
+
if (platform === 'win32')
|
|
84
|
+
return dpapiProtector;
|
|
85
|
+
if (platform === 'darwin')
|
|
86
|
+
return commandProtector('darwin');
|
|
87
|
+
if (platform === 'linux')
|
|
88
|
+
return commandProtector('linux');
|
|
89
|
+
throw new Error(`unsupported platform '${platform}': Khat vault protection requires Windows DPAPI, macOS Keychain, or Linux Secret Service`);
|
|
90
|
+
}
|
|
91
|
+
/** Test seam for command-backed platform implementations. */
|
|
92
|
+
export function createCommandKeyProtector(platform, runner) {
|
|
93
|
+
return commandProtector(platform, runner);
|
|
94
|
+
}
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, rename, stat, unlink } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
|
4
|
+
const DEFAULT_HISTORY_COUNT = 5;
|
|
5
|
+
export class LogWriter {
|
|
6
|
+
directory;
|
|
7
|
+
maxBytes;
|
|
8
|
+
historyCount;
|
|
9
|
+
filePath;
|
|
10
|
+
queue = Promise.resolve();
|
|
11
|
+
constructor(directory, maxBytes = DEFAULT_MAX_BYTES, historyCount = DEFAULT_HISTORY_COUNT) {
|
|
12
|
+
this.directory = directory;
|
|
13
|
+
this.maxBytes = maxBytes;
|
|
14
|
+
this.historyCount = historyCount;
|
|
15
|
+
this.filePath = join(directory, 'khat.jsonl');
|
|
16
|
+
}
|
|
17
|
+
append(entry) {
|
|
18
|
+
this.queue = this.queue.then(async () => {
|
|
19
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
20
|
+
await this.rotateIfNeeded();
|
|
21
|
+
await appendFile(this.filePath, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
22
|
+
});
|
|
23
|
+
return this.queue;
|
|
24
|
+
}
|
|
25
|
+
async recent(tail = 50) {
|
|
26
|
+
const count = Math.max(1, Math.min(Math.floor(tail), 1_000));
|
|
27
|
+
let content;
|
|
28
|
+
try {
|
|
29
|
+
content = await readFile(this.filePath, 'utf8');
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error?.code === 'ENOENT')
|
|
33
|
+
return [];
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
const entries = [];
|
|
37
|
+
for (const line of content.split('\n')) {
|
|
38
|
+
if (!line)
|
|
39
|
+
continue;
|
|
40
|
+
try {
|
|
41
|
+
entries.push(JSON.parse(line));
|
|
42
|
+
}
|
|
43
|
+
catch { }
|
|
44
|
+
}
|
|
45
|
+
return entries.slice(-count);
|
|
46
|
+
}
|
|
47
|
+
async rotateIfNeeded() {
|
|
48
|
+
try {
|
|
49
|
+
if ((await stat(this.filePath)).size < this.maxBytes)
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error?.code === 'ENOENT')
|
|
54
|
+
return;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
for (let index = this.historyCount - 1; index >= 1; index--) {
|
|
58
|
+
const from = `${this.filePath}.${index}`;
|
|
59
|
+
const to = `${this.filePath}.${index + 1}`;
|
|
60
|
+
if (index === this.historyCount - 1)
|
|
61
|
+
await unlink(to).catch(() => undefined);
|
|
62
|
+
await rename(from, to).catch((error) => { if (error?.code !== 'ENOENT')
|
|
63
|
+
throw error; });
|
|
64
|
+
}
|
|
65
|
+
await rename(this.filePath, `${this.filePath}.1`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes } from 'node:crypto';
|
|
2
|
+
import { access, mkdir, readFile, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { saveJsonAtomic, validateConfig } from './config.js';
|
|
5
|
+
import { Vault } from './vault.js';
|
|
6
|
+
const FORMAT = 'khat-vault-export';
|
|
7
|
+
const VERSION = 1;
|
|
8
|
+
const AAD = 'khat-vault-export-v1';
|
|
9
|
+
const ITERATIONS = 210_000;
|
|
10
|
+
const KEY_LENGTH = 32;
|
|
11
|
+
const MAX_CIPHERTEXT = 16 * 1024 * 1024;
|
|
12
|
+
const B64 = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
13
|
+
function b64(value) { return value.toString('base64'); }
|
|
14
|
+
function decode(value, name, min = 1, max = 1024) {
|
|
15
|
+
if (typeof value !== 'string' || !B64.test(value) || value.length % 4 !== 0)
|
|
16
|
+
throw new Error(`invalid export ${name}`);
|
|
17
|
+
const result = Buffer.from(value, 'base64');
|
|
18
|
+
if (result.length < min || result.length > max)
|
|
19
|
+
throw new Error(`invalid export ${name} length`);
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
export function makeExport(secrets, password, config) {
|
|
23
|
+
if (!password)
|
|
24
|
+
throw new Error('export password must not be empty');
|
|
25
|
+
const salt = randomBytes(16), nonce = randomBytes(12);
|
|
26
|
+
const key = pbkdf2Sync(password, salt, ITERATIONS, KEY_LENGTH, 'sha256');
|
|
27
|
+
const cipher = createCipheriv('aes-256-gcm', key, nonce);
|
|
28
|
+
cipher.setAAD(Buffer.from(AAD));
|
|
29
|
+
const ciphertext = Buffer.concat([cipher.update(JSON.stringify({ secrets, ...(config ? { config: structuredClone(config) } : {}) })), cipher.final()]);
|
|
30
|
+
const envelope = { format: FORMAT, version: VERSION, kdf: { name: 'pbkdf2', hash: 'sha256', iterations: ITERATIONS, salt: b64(salt), keyLength: KEY_LENGTH }, cipher: { name: 'aes-256-gcm', nonce: b64(nonce), ciphertext: b64(ciphertext), tag: b64(cipher.getAuthTag()) } };
|
|
31
|
+
return Buffer.from(JSON.stringify(envelope, null, 2) + '\n');
|
|
32
|
+
}
|
|
33
|
+
export async function exportPortable(path, vault, password, config) {
|
|
34
|
+
await saveJsonAtomic(path, JSON.parse(makeExport(vault.secrets, password, config).toString('utf8')));
|
|
35
|
+
}
|
|
36
|
+
export function readExport(raw, password) {
|
|
37
|
+
if (!password)
|
|
38
|
+
throw new Error('import password must not be empty');
|
|
39
|
+
let envelope;
|
|
40
|
+
try {
|
|
41
|
+
envelope = JSON.parse(raw.toString());
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new Error('export file is not valid JSON');
|
|
45
|
+
}
|
|
46
|
+
if (envelope?.format !== FORMAT || envelope.version !== VERSION)
|
|
47
|
+
throw new Error('unsupported export format or version');
|
|
48
|
+
const kdf = envelope.kdf, cipher = envelope.cipher;
|
|
49
|
+
if (kdf?.name !== 'pbkdf2' || kdf.hash !== 'sha256' || kdf.keyLength !== KEY_LENGTH || kdf.iterations !== ITERATIONS)
|
|
50
|
+
throw new Error('unsupported or unsafe export KDF parameters');
|
|
51
|
+
const salt = decode(kdf.salt, 'salt', 16, 16), nonce = decode(cipher?.nonce, 'nonce', 12, 12), tag = decode(cipher?.tag, 'tag', 16, 16), ciphertext = decode(cipher?.ciphertext, 'ciphertext', 1, MAX_CIPHERTEXT);
|
|
52
|
+
try {
|
|
53
|
+
const decipher = createDecipheriv('aes-256-gcm', pbkdf2Sync(password, salt, ITERATIONS, KEY_LENGTH, 'sha256'), nonce);
|
|
54
|
+
decipher.setAAD(Buffer.from(AAD));
|
|
55
|
+
decipher.setAuthTag(tag);
|
|
56
|
+
return JSON.parse(Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new Error('could not decrypt export (wrong password or corrupted file)');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function validatePayload(payload) {
|
|
63
|
+
if (!payload || typeof payload !== 'object' || !payload.secrets || typeof payload.secrets !== 'object' || Array.isArray(payload.secrets))
|
|
64
|
+
throw new Error('export payload has invalid secrets');
|
|
65
|
+
for (const [ref, value] of Object.entries(payload.secrets))
|
|
66
|
+
if (!ref || typeof value !== 'string' || !value)
|
|
67
|
+
throw new Error('export payload has invalid secret');
|
|
68
|
+
if (payload.config) {
|
|
69
|
+
validateConfig(payload.config);
|
|
70
|
+
const refs = new Set();
|
|
71
|
+
for (const provider of payload.config.providers ?? [])
|
|
72
|
+
for (const key of provider.keys ?? []) {
|
|
73
|
+
if (key.vaultRef !== `${provider.id}/${key.id}`)
|
|
74
|
+
throw new Error(`export has inconsistent vaultRef for ${provider.id}/${key.id}`);
|
|
75
|
+
refs.add(key.vaultRef);
|
|
76
|
+
}
|
|
77
|
+
for (const ref of refs)
|
|
78
|
+
if (!(ref in payload.secrets))
|
|
79
|
+
throw new Error(`export is missing secret for ${ref}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export async function importPortable(path, targetDir, protector, password, force = false) {
|
|
83
|
+
const payload = readExport(await readFile(path), password);
|
|
84
|
+
validatePayload(payload);
|
|
85
|
+
await mkdir(targetDir, { recursive: true, mode: 0o700 });
|
|
86
|
+
const configPath = join(targetDir, 'config.json'), vaultPath = join(targetDir, 'vault.json'), statePath = join(targetDir, 'state.json');
|
|
87
|
+
if (!force) {
|
|
88
|
+
for (const item of [configPath, vaultPath, statePath]) {
|
|
89
|
+
try {
|
|
90
|
+
await access(item);
|
|
91
|
+
throw new Error(`target is already initialized at ${targetDir}; use --force to replace it`);
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
if (e.message?.startsWith('target is already'))
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const stage = join(targetDir, `.import-${process.pid}-${randomBytes(6).toString('hex')}`);
|
|
100
|
+
await mkdir(stage, { mode: 0o700 });
|
|
101
|
+
try {
|
|
102
|
+
const vault = await Vault.create(join(stage, 'vault.json'), protector);
|
|
103
|
+
for (const [ref, value] of Object.entries(payload.secrets))
|
|
104
|
+
vault.set(ref, value);
|
|
105
|
+
await vault.save();
|
|
106
|
+
await saveJsonAtomic(join(stage, 'config.json'), payload.config ?? { version: 1, bind: '127.0.0.1', port: 8787, requestBodyLimitMB: 64, providers: [], routes: [] });
|
|
107
|
+
await saveJsonAtomic(join(stage, 'state.json'), { keys: {}, counters: {} });
|
|
108
|
+
const backup = force ? join(targetDir, `.import-backup-${process.pid}-${randomBytes(6).toString('hex')}`) : undefined;
|
|
109
|
+
if (backup)
|
|
110
|
+
await mkdir(backup, { mode: 0o700 });
|
|
111
|
+
const moved = [];
|
|
112
|
+
try {
|
|
113
|
+
for (const name of ['vault.json', 'config.json', 'state.json']) {
|
|
114
|
+
const destination = join(targetDir, name);
|
|
115
|
+
if (backup) {
|
|
116
|
+
try {
|
|
117
|
+
await rename(destination, join(backup, name));
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error?.code !== 'ENOENT')
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
await rename(join(stage, name), destination);
|
|
125
|
+
moved.push(name);
|
|
126
|
+
}
|
|
127
|
+
if (backup)
|
|
128
|
+
await rm(backup, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
for (const name of moved)
|
|
132
|
+
await rm(join(targetDir, name), { force: true });
|
|
133
|
+
if (backup)
|
|
134
|
+
for (const name of ['vault.json', 'config.json', 'state.json']) {
|
|
135
|
+
try {
|
|
136
|
+
await rename(join(backup, name), join(targetDir, name));
|
|
137
|
+
}
|
|
138
|
+
catch { }
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
await rm(stage, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function resolveProvider(config, model) {
|
|
2
|
+
const slash = model.indexOf('/');
|
|
3
|
+
if (slash > 0) {
|
|
4
|
+
const providerId = model.slice(0, slash);
|
|
5
|
+
const provider = config.providers.find((item) => item.id === providerId);
|
|
6
|
+
return provider && config.routes.some((route) => route.provider === providerId && route.model === model.slice(slash + 1)) ? provider : undefined;
|
|
7
|
+
}
|
|
8
|
+
const route = config.routes.find((item) => item.model === model);
|
|
9
|
+
return route ? config.providers.find((item) => item.id === route.provider) : undefined;
|
|
10
|
+
}
|
package/dist/selector.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export class WeightedSelector {
|
|
2
|
+
current = new Map();
|
|
3
|
+
select(providerId, keys, states) {
|
|
4
|
+
const available = keys.filter((key) => states[`${providerId}/${key.id}`]?.status !== 'unavailable');
|
|
5
|
+
if (!available.length)
|
|
6
|
+
return undefined;
|
|
7
|
+
let total = 0;
|
|
8
|
+
let selected = available[0];
|
|
9
|
+
let best = Number.NEGATIVE_INFINITY;
|
|
10
|
+
for (const key of available) {
|
|
11
|
+
const id = `${providerId}/${key.id}`;
|
|
12
|
+
const weight = (this.current.get(id) ?? 0) + key.weight;
|
|
13
|
+
this.current.set(id, weight);
|
|
14
|
+
total += key.weight;
|
|
15
|
+
if (weight > best) {
|
|
16
|
+
best = weight;
|
|
17
|
+
selected = key;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
this.current.set(`${providerId}/${selected.id}`, (this.current.get(`${providerId}/${selected.id}`) ?? 0) - total);
|
|
21
|
+
return selected;
|
|
22
|
+
}
|
|
23
|
+
}
|