@yeaft/webchat-agent 1.0.188 → 1.0.190
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/cli.js +12 -0
- package/index.js +10 -2
- package/local-run.js +218 -0
- package/local-runtime/server/.env.example +54 -0
- package/local-runtime/server/api.js +111 -0
- package/local-runtime/server/auth/aad.js +235 -0
- package/local-runtime/server/auth/login.js +156 -0
- package/local-runtime/server/auth/oauth-flow.js +277 -0
- package/local-runtime/server/auth/password-reset.js +134 -0
- package/local-runtime/server/auth/providers/alipay.js +125 -0
- package/local-runtime/server/auth/providers/github.js +82 -0
- package/local-runtime/server/auth/providers/google.js +60 -0
- package/local-runtime/server/auth/providers/microsoft.js +71 -0
- package/local-runtime/server/auth/providers/types.js +57 -0
- package/local-runtime/server/auth/providers/wechat.js +68 -0
- package/local-runtime/server/auth/register.js +91 -0
- package/local-runtime/server/auth/session-store.js +57 -0
- package/local-runtime/server/auth/token.js +85 -0
- package/local-runtime/server/auth/totp-auth.js +133 -0
- package/local-runtime/server/auth/utils.js +42 -0
- package/local-runtime/server/auth.js +8 -0
- package/local-runtime/server/check-node-version.js +74 -0
- package/local-runtime/server/config.js +298 -0
- package/local-runtime/server/context.js +140 -0
- package/local-runtime/server/create-user.js +59 -0
- package/local-runtime/server/database.js +12 -0
- package/local-runtime/server/db/connection.js +963 -0
- package/local-runtime/server/db/expert-db.js +171 -0
- package/local-runtime/server/db/identity-db.js +92 -0
- package/local-runtime/server/db/invitation-db.js +38 -0
- package/local-runtime/server/db/message-db.js +257 -0
- package/local-runtime/server/db/session-db.js +118 -0
- package/local-runtime/server/db/user-db.js +165 -0
- package/local-runtime/server/db/user-stats-db.js +185 -0
- package/local-runtime/server/db/yeaft-session-db.js +258 -0
- package/local-runtime/server/email.js +96 -0
- package/local-runtime/server/encryption.js +105 -0
- package/local-runtime/server/handlers/agent-conversation.js +347 -0
- package/local-runtime/server/handlers/agent-file-terminal.js +99 -0
- package/local-runtime/server/handlers/agent-output.js +854 -0
- package/local-runtime/server/handlers/agent-sync.js +399 -0
- package/local-runtime/server/handlers/agent-work-center.js +27 -0
- package/local-runtime/server/handlers/client-conversation.js +1182 -0
- package/local-runtime/server/handlers/client-misc.js +254 -0
- package/local-runtime/server/handlers/client-work-center.js +269 -0
- package/local-runtime/server/handlers/client-workbench.js +146 -0
- package/local-runtime/server/handlers/session-pin-router.js +61 -0
- package/local-runtime/server/heartbeat-policy.js +46 -0
- package/local-runtime/server/index.js +275 -0
- package/local-runtime/server/package.json +55 -0
- package/local-runtime/server/perf-trace.js +154 -0
- package/local-runtime/server/proxy.js +273 -0
- package/local-runtime/server/routes/admin-routes.js +207 -0
- package/local-runtime/server/routes/auth-routes.js +322 -0
- package/local-runtime/server/routes/expert-routes.js +117 -0
- package/local-runtime/server/routes/invitation-routes.js +60 -0
- package/local-runtime/server/routes/session-routes.js +112 -0
- package/local-runtime/server/routes/upload-routes.js +109 -0
- package/local-runtime/server/routes/user-routes.js +241 -0
- package/local-runtime/server/totp.js +74 -0
- package/local-runtime/server/work-item-attachment-policy.js +56 -0
- package/local-runtime/server/ws-agent.js +319 -0
- package/local-runtime/server/ws-client.js +214 -0
- package/local-runtime/server/ws-utils.js +394 -0
- package/local-runtime/server/yeaft-asset-store.js +339 -0
- package/local-runtime/version.json +1 -0
- package/local-runtime/web/app.bundle.js +7673 -0
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/assets/avatars/README.md +34 -0
- package/local-runtime/web/assets/avatars/ada.svg +1 -0
- package/local-runtime/web/assets/avatars/alan.svg +1 -0
- package/local-runtime/web/assets/avatars/alice.svg +1 -0
- package/local-runtime/web/assets/avatars/anders.svg +1 -0
- package/local-runtime/web/assets/avatars/bezos.svg +1 -0
- package/local-runtime/web/assets/avatars/borges.svg +1 -0
- package/local-runtime/web/assets/avatars/buffett.svg +1 -0
- package/local-runtime/web/assets/avatars/clausewitz.svg +1 -0
- package/local-runtime/web/assets/avatars/dalio.svg +1 -0
- package/local-runtime/web/assets/avatars/dieter.svg +1 -0
- package/local-runtime/web/assets/avatars/drucker.svg +1 -0
- package/local-runtime/web/assets/avatars/einstein.svg +1 -0
- package/local-runtime/web/assets/avatars/grace.svg +1 -0
- package/local-runtime/web/assets/avatars/harari.svg +1 -0
- package/local-runtime/web/assets/avatars/jung.svg +1 -0
- package/local-runtime/web/assets/avatars/kahneman.svg +1 -0
- package/local-runtime/web/assets/avatars/ken.svg +1 -0
- package/local-runtime/web/assets/avatars/kongzi.svg +1 -0
- package/local-runtime/web/assets/avatars/kubrick.svg +1 -0
- package/local-runtime/web/assets/avatars/linus.svg +1 -0
- package/local-runtime/web/assets/avatars/luxun.svg +1 -0
- package/local-runtime/web/assets/avatars/margaret.svg +1 -0
- package/local-runtime/web/assets/avatars/martin.svg +1 -0
- package/local-runtime/web/assets/avatars/miyazaki.svg +1 -0
- package/local-runtime/web/assets/avatars/munger.svg +1 -0
- package/local-runtime/web/assets/avatars/nietzsche.svg +1 -0
- package/local-runtime/web/assets/avatars/norman.svg +1 -0
- package/local-runtime/web/assets/avatars/shannon.svg +1 -0
- package/local-runtime/web/assets/avatars/simaqian.svg +1 -0
- package/local-runtime/web/assets/avatars/socrates.svg +1 -0
- package/local-runtime/web/assets/avatars/steve.svg +1 -0
- package/local-runtime/web/assets/avatars/sudongpo.svg +1 -0
- package/local-runtime/web/assets/avatars/sunzi.svg +1 -0
- package/local-runtime/web/docx-preview.min.js +2 -0
- package/local-runtime/web/docx-preview.min.js.gz +0 -0
- package/local-runtime/web/html-to-image.min.js +2 -0
- package/local-runtime/web/html-to-image.min.js.gz +0 -0
- package/local-runtime/web/index.html +21 -0
- package/local-runtime/web/jszip.min.js +13 -0
- package/local-runtime/web/jszip.min.js.gz +0 -0
- package/local-runtime/web/mermaid.min.js +2843 -0
- package/local-runtime/web/mermaid.min.js.gz +0 -0
- package/local-runtime/web/msal-browser.min.js +69 -0
- package/local-runtime/web/msal-browser.min.js.gz +0 -0
- package/local-runtime/web/style.bundle.css +10 -0
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/local-runtime/web/vendor.bundle.js +1522 -0
- package/local-runtime/web/vendor.bundle.js.gz +0 -0
- package/local-runtime/web/xlsx.min.js +24 -0
- package/local-runtime/web/xlsx.min.js.gz +0 -0
- package/package.json +13 -3
- package/scripts/prepare-local-runtime.js +25 -0
package/cli.js
CHANGED
|
@@ -49,6 +49,14 @@ if (command === 'doctor') {
|
|
|
49
49
|
await handleDoctorCommand();
|
|
50
50
|
} else if (command === 'llm') {
|
|
51
51
|
await handleLlmCommand(subArgs);
|
|
52
|
+
} else if (command === 'local') {
|
|
53
|
+
try {
|
|
54
|
+
const { runLocal } = await import('./local-run.js');
|
|
55
|
+
await runLocal(subArgs);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error(`Local run failed: ${error.message}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
52
60
|
} else if (command === 'upgrade') {
|
|
53
61
|
upgrade();
|
|
54
62
|
} else if (command === '--version' || command === '-v') {
|
|
@@ -68,6 +76,7 @@ function printHelp() {
|
|
|
68
76
|
|
|
69
77
|
Usage:
|
|
70
78
|
yeaft-agent [options] Run agent in foreground
|
|
79
|
+
yeaft-agent local --name <name> Run local Web UI, server, and agent
|
|
71
80
|
yeaft-agent install [options] Install as system service
|
|
72
81
|
yeaft-agent uninstall [options] Remove system service
|
|
73
82
|
yeaft-agent start [options] Start installed service
|
|
@@ -84,6 +93,7 @@ function printHelp() {
|
|
|
84
93
|
--instance <id> Deprecated alias for the local service instance id
|
|
85
94
|
--server <url> WebSocket server URL (default: ws://localhost:3456)
|
|
86
95
|
--name <name> Agent name and instance id (letters, numbers, ._-)
|
|
96
|
+
--port <port> Local server port (local command only; default: 6868)
|
|
87
97
|
--secret <secret> Agent secret for authentication
|
|
88
98
|
--work-dir <dir> Default working directory (default: cwd)
|
|
89
99
|
--yeaft-dir <dir> Yeaft data directory for this instance
|
|
@@ -98,6 +108,8 @@ function printHelp() {
|
|
|
98
108
|
YEAFT_DIR Yeaft data directory
|
|
99
109
|
|
|
100
110
|
Examples:
|
|
111
|
+
yeaft-agent local --name my-worker
|
|
112
|
+
yeaft-agent local --name my-worker --port 7000
|
|
101
113
|
yeaft-agent --server wss://your-server.com --name my-worker --secret xxx
|
|
102
114
|
yeaft-agent install --server wss://your-server.com --name my-worker --secret xxx
|
|
103
115
|
yeaft-agent install --server wss://your-server.com --name my-worker-2 --secret xxx
|
package/index.js
CHANGED
|
@@ -25,6 +25,7 @@ ctx.pkgName = pkg.name;
|
|
|
25
25
|
|
|
26
26
|
// 配置文件路径(向后兼容:先查当前目录 .claude-agent.json)
|
|
27
27
|
const LOCAL_CONFIG_FILE = join(process.cwd(), '.claude-agent.json');
|
|
28
|
+
const IS_LOCAL_RUN = process.env.YEAFT_LOCAL_RUN === 'true';
|
|
28
29
|
|
|
29
30
|
// 加载或创建配置
|
|
30
31
|
function loadConfig() {
|
|
@@ -36,6 +37,10 @@ function loadConfig() {
|
|
|
36
37
|
agentSecret: 'agent-shared-secret'
|
|
37
38
|
};
|
|
38
39
|
|
|
40
|
+
// Local run receives its complete connection identity from the launcher and
|
|
41
|
+
// must not inherit a remote agent's persisted configuration.
|
|
42
|
+
if (IS_LOCAL_RUN) return defaults;
|
|
43
|
+
|
|
39
44
|
// Priority 1: Local .claude-agent.json (backward compat)
|
|
40
45
|
if (existsSync(LOCAL_CONFIG_FILE)) {
|
|
41
46
|
try {
|
|
@@ -57,6 +62,7 @@ function loadConfig() {
|
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
function saveConfig(config) {
|
|
65
|
+
if (IS_LOCAL_RUN) return;
|
|
60
66
|
writeFileSync(LOCAL_CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
61
67
|
}
|
|
62
68
|
|
|
@@ -336,8 +342,10 @@ process.on('SIGTERM', async () => {
|
|
|
336
342
|
|
|
337
343
|
// 启动 - 先确保依赖,再检测能力,预热 models.dev 缓存,再连接
|
|
338
344
|
(async () => {
|
|
339
|
-
|
|
340
|
-
|
|
345
|
+
if (process.env.YEAFT_SKIP_STARTUP_INSTALLS !== 'true') {
|
|
346
|
+
await ensureDependencies();
|
|
347
|
+
await ensureYeaftSkills();
|
|
348
|
+
}
|
|
341
349
|
ctx.agentCapabilities = await detectCapabilities();
|
|
342
350
|
// Prime the models.dev community catalog so the Yeaft engine's *synchronous*
|
|
343
351
|
// hot path (engine.js / config.js / cli.js all read context-window inline)
|
package/local-run.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { createServer } from 'net';
|
|
5
|
+
import { dirname, join } from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { WebSocket } from 'ws';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_PORT = 6868;
|
|
10
|
+
const LOCAL_HOST = '127.0.0.1';
|
|
11
|
+
|
|
12
|
+
export function parseLocalArgs(args) {
|
|
13
|
+
const options = { name: '', port: DEFAULT_PORT };
|
|
14
|
+
for (let i = 0; i < args.length; i++) {
|
|
15
|
+
const arg = args[i];
|
|
16
|
+
const value = args[i + 1];
|
|
17
|
+
if (arg === '--name') {
|
|
18
|
+
if (!value || value.startsWith('-')) throw new Error('--name requires a value');
|
|
19
|
+
options.name = value;
|
|
20
|
+
i++;
|
|
21
|
+
} else if (arg === '--port') {
|
|
22
|
+
if (!value || value.startsWith('-')) throw new Error('--port requires a value');
|
|
23
|
+
if (!/^\d+$/.test(value)) throw new Error(`Invalid port: ${value}`);
|
|
24
|
+
options.port = Number(value);
|
|
25
|
+
if (options.port < 1 || options.port > 65535) throw new Error(`Invalid port: ${value}`);
|
|
26
|
+
i++;
|
|
27
|
+
} else {
|
|
28
|
+
throw new Error(`Unknown local option: ${arg}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!options.name) throw new Error('local requires --name <name>');
|
|
32
|
+
if (!/^[A-Za-z0-9._-]+$/.test(options.name)) {
|
|
33
|
+
throw new Error('Invalid name: use only letters, numbers, dot, underscore, or hyphen');
|
|
34
|
+
}
|
|
35
|
+
return options;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function runtimePaths() {
|
|
39
|
+
const agentDir = dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
const packaged = join(agentDir, 'local-runtime');
|
|
41
|
+
if (existsSync(packaged)) {
|
|
42
|
+
return {
|
|
43
|
+
serverEntry: join(packaged, 'server', 'index.js'),
|
|
44
|
+
webDir: join(packaged, 'web'),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
serverEntry: join(agentDir, '..', 'server', 'index.js'),
|
|
49
|
+
webDir: join(agentDir, '..', 'web'),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function assertPortAvailable(port) {
|
|
54
|
+
const probe = createServer();
|
|
55
|
+
await new Promise((resolve, reject) => {
|
|
56
|
+
probe.once('error', reject);
|
|
57
|
+
probe.listen(port, LOCAL_HOST, resolve);
|
|
58
|
+
}).catch(error => {
|
|
59
|
+
if (error.code === 'EADDRINUSE') {
|
|
60
|
+
throw new Error(`Port ${port} is already in use on ${LOCAL_HOST}`);
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
});
|
|
64
|
+
await new Promise((resolve, reject) => probe.close(error => error ? reject(error) : resolve()));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function waitForServer(url, server, timeoutMs = 15000) {
|
|
68
|
+
const deadline = Date.now() + timeoutMs;
|
|
69
|
+
while (Date.now() < deadline) {
|
|
70
|
+
if (server.exitCode !== null) throw new Error(`Local server exited with code ${server.exitCode}`);
|
|
71
|
+
try {
|
|
72
|
+
const response = await fetch(`${url}/api/auth/mode`);
|
|
73
|
+
if (response.ok) {
|
|
74
|
+
const mode = await response.json();
|
|
75
|
+
if (!mode.skipAuth) throw new Error('Local server did not start in no-auth mode');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error.message === 'Local server did not start in no-auth mode') throw error;
|
|
80
|
+
// Server is still starting.
|
|
81
|
+
}
|
|
82
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`Local server did not become ready at ${url}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function waitForAgent(wsUrl, name, agent, timeoutMs = 15000) {
|
|
88
|
+
const deadline = Date.now() + timeoutMs;
|
|
89
|
+
while (Date.now() < deadline) {
|
|
90
|
+
if (agent.exitCode !== null) throw new Error(`Local agent exited with code ${agent.exitCode}`);
|
|
91
|
+
try {
|
|
92
|
+
const agents = await readAgentList(wsUrl);
|
|
93
|
+
if (agents.some(item => item.name === name || item.agentName === name)) return;
|
|
94
|
+
} catch {
|
|
95
|
+
// Agent is still connecting.
|
|
96
|
+
}
|
|
97
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`Local agent did not register as ${name}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readAgentList(wsUrl) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const ws = new WebSocket(`${wsUrl}?type=web`);
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
ws.terminate();
|
|
107
|
+
reject(new Error('Timed out waiting for agent list'));
|
|
108
|
+
}, 1000);
|
|
109
|
+
ws.on('open', () => ws.send(JSON.stringify({ type: 'client_hello', plaintextOk: true })));
|
|
110
|
+
ws.on('message', data => {
|
|
111
|
+
try {
|
|
112
|
+
const message = JSON.parse(data.toString());
|
|
113
|
+
if (message.type !== 'agent_list') return;
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
ws.close();
|
|
116
|
+
resolve(message.agents || []);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
ws.terminate();
|
|
120
|
+
reject(error);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
ws.on('error', error => {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
ws.terminate();
|
|
126
|
+
reject(error);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function runLocal(args, options = {}) {
|
|
132
|
+
const config = parseLocalArgs(args);
|
|
133
|
+
const paths = options.paths || runtimePaths();
|
|
134
|
+
const dataDir = options.dataDir || join(homedir(), '.yeaft', 'server');
|
|
135
|
+
const url = `http://${LOCAL_HOST}:${config.port}`;
|
|
136
|
+
const children = new Set();
|
|
137
|
+
const signalHandlers = new Map();
|
|
138
|
+
let stopping = false;
|
|
139
|
+
let stopPromise = null;
|
|
140
|
+
|
|
141
|
+
const stop = (exitCode = 0) => {
|
|
142
|
+
if (stopPromise) return stopPromise;
|
|
143
|
+
stopping = true;
|
|
144
|
+
stopPromise = (async () => {
|
|
145
|
+
for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler);
|
|
146
|
+
for (const child of children) {
|
|
147
|
+
if (child.exitCode === null && !child.killed) child.kill('SIGTERM');
|
|
148
|
+
}
|
|
149
|
+
await Promise.all([...children].map(child => new Promise(resolve => {
|
|
150
|
+
if (child.exitCode !== null) return resolve();
|
|
151
|
+
const timer = setTimeout(() => {
|
|
152
|
+
if (child.exitCode === null) child.kill('SIGKILL');
|
|
153
|
+
resolve();
|
|
154
|
+
}, 6000);
|
|
155
|
+
child.once('exit', () => { clearTimeout(timer); resolve(); });
|
|
156
|
+
})));
|
|
157
|
+
if (options.exit !== false) process.exit(exitCode);
|
|
158
|
+
})();
|
|
159
|
+
return stopPromise;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
await assertPortAvailable(config.port);
|
|
163
|
+
|
|
164
|
+
signalHandlers.set('SIGINT', () => void stop(0));
|
|
165
|
+
signalHandlers.set('SIGTERM', () => void stop(0));
|
|
166
|
+
for (const [signal, handler] of signalHandlers) process.once(signal, handler);
|
|
167
|
+
|
|
168
|
+
const spawnChild = options.spawn || spawn;
|
|
169
|
+
try {
|
|
170
|
+
const server = spawnChild(process.execPath, [paths.serverEntry], {
|
|
171
|
+
stdio: 'inherit',
|
|
172
|
+
env: {
|
|
173
|
+
...process.env,
|
|
174
|
+
PORT: String(config.port),
|
|
175
|
+
SERVER_HOST: LOCAL_HOST,
|
|
176
|
+
SERVER_DATA_DIR: dataDir,
|
|
177
|
+
PERF_TRACE_DIR: join(dataDir, 'perf-traces'),
|
|
178
|
+
SKIP_AUTH: 'true',
|
|
179
|
+
WEB_DIR: paths.webDir,
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
children.add(server);
|
|
183
|
+
|
|
184
|
+
await (options.waitForServer || waitForServer)(url, server);
|
|
185
|
+
|
|
186
|
+
const agentDir = dirname(fileURLToPath(import.meta.url));
|
|
187
|
+
const agent = spawnChild(process.execPath, [join(agentDir, 'index.js')], {
|
|
188
|
+
stdio: 'inherit',
|
|
189
|
+
env: {
|
|
190
|
+
...process.env,
|
|
191
|
+
SERVER_URL: `ws://${LOCAL_HOST}:${config.port}`,
|
|
192
|
+
AGENT_NAME: config.name,
|
|
193
|
+
YEAFT_AGENT_INSTANCE: config.name,
|
|
194
|
+
AGENT_SECRET: '',
|
|
195
|
+
YEAFT_LOCAL_RUN: 'true',
|
|
196
|
+
YEAFT_SKIP_STARTUP_INSTALLS: 'true',
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
children.add(agent);
|
|
200
|
+
|
|
201
|
+
const fail = childName => code => {
|
|
202
|
+
if (!stopping) {
|
|
203
|
+
console.error(`${childName} exited unexpectedly with code ${code}`);
|
|
204
|
+
void stop(code || 1);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
server.once('exit', fail('Local server'));
|
|
208
|
+
agent.once('exit', fail('Local agent'));
|
|
209
|
+
|
|
210
|
+
await waitForAgent(`ws://${LOCAL_HOST}:${config.port}`, config.name, agent);
|
|
211
|
+
|
|
212
|
+
console.log(`Yeaft local is available at ${url}`);
|
|
213
|
+
return { url, server, agent, stop };
|
|
214
|
+
} catch (error) {
|
|
215
|
+
await stop(1);
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# WebChat Server Configuration
|
|
2
|
+
|
|
3
|
+
# Server port
|
|
4
|
+
PORT=3456
|
|
5
|
+
|
|
6
|
+
# Skip authentication (development mode)
|
|
7
|
+
# Set to "true" to bypass login
|
|
8
|
+
SKIP_AUTH=false
|
|
9
|
+
|
|
10
|
+
# User configuration (two options)
|
|
11
|
+
#
|
|
12
|
+
# Option 1: Environment variable
|
|
13
|
+
# Format: username:bcryptHash:email:totpSecret:totpEnabled
|
|
14
|
+
# Multiple users separated by comma
|
|
15
|
+
# Generate hash with: node -e "require('bcrypt').hash('password', 10).then(console.log)"
|
|
16
|
+
# AUTH_USERS=admin:$2b$10$xxxxx:admin@example.com::false
|
|
17
|
+
#
|
|
18
|
+
# Option 2: users.json file (recommended)
|
|
19
|
+
# See users.example.json for format
|
|
20
|
+
|
|
21
|
+
# SMTP configuration for email verification
|
|
22
|
+
# If not configured, email verification will be skipped
|
|
23
|
+
SMTP_HOST=smtp.gmail.com
|
|
24
|
+
SMTP_PORT=587
|
|
25
|
+
SMTP_SECURE=false
|
|
26
|
+
SMTP_USER=your@gmail.com
|
|
27
|
+
SMTP_PASS=your-app-password
|
|
28
|
+
SMTP_FROM=WebChat <noreply@example.com>
|
|
29
|
+
|
|
30
|
+
# JWT configuration
|
|
31
|
+
JWT_SECRET=your-random-secret-key-change-this
|
|
32
|
+
JWT_EXPIRES_IN=3d
|
|
33
|
+
# Sliding renew threshold (ms). When a request's token has less than this much
|
|
34
|
+
# life left, the server issues a refreshed token via the X-New-Token response
|
|
35
|
+
# header. Default: 1 day.
|
|
36
|
+
JWT_RENEW_THRESHOLD_MS=86400000
|
|
37
|
+
TEMP_TOKEN_EXPIRES_IN=10m
|
|
38
|
+
|
|
39
|
+
# Email verification code settings
|
|
40
|
+
EMAIL_CODE_LENGTH=6
|
|
41
|
+
EMAIL_CODE_EXPIRES_IN=300000
|
|
42
|
+
|
|
43
|
+
# Agent authentication
|
|
44
|
+
# Agents must provide this secret to connect
|
|
45
|
+
AGENT_SECRET=agent-shared-secret
|
|
46
|
+
|
|
47
|
+
# File upload settings
|
|
48
|
+
MAX_FILE_SIZE=52428800
|
|
49
|
+
FILE_CLEANUP_INTERVAL=600000
|
|
50
|
+
|
|
51
|
+
# TOTP (Two-Factor Authentication) settings
|
|
52
|
+
TOTP_ENABLED=true
|
|
53
|
+
TOTP_ISSUER=Claude Web Chat
|
|
54
|
+
TOTP_WINDOW=1
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { CONFIG } from './config.js';
|
|
5
|
+
import { verifyToken, maybeRenewToken } from './auth.js';
|
|
6
|
+
import { registerAuthRoutes } from './routes/auth-routes.js';
|
|
7
|
+
import { registerInvitationRoutes } from './routes/invitation-routes.js';
|
|
8
|
+
import { registerUserRoutes } from './routes/user-routes.js';
|
|
9
|
+
import { registerSessionRoutes } from './routes/session-routes.js';
|
|
10
|
+
import { registerUploadRoutes } from './routes/upload-routes.js';
|
|
11
|
+
import { registerAdminRoutes } from './routes/admin-routes.js';
|
|
12
|
+
import { registerExpertRoutes } from './routes/expert-routes.js';
|
|
13
|
+
|
|
14
|
+
// 登录速率限制: IP -> { attempts, resetAt }
|
|
15
|
+
const loginAttempts = new Map();
|
|
16
|
+
const LOGIN_MAX_ATTEMPTS = 30;
|
|
17
|
+
const LOGIN_WINDOW_MS = 5 * 60 * 1000; // 5 分钟窗口
|
|
18
|
+
|
|
19
|
+
function checkRateLimit(ip) {
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
const record = loginAttempts.get(ip);
|
|
22
|
+
if (!record || now > record.resetAt) {
|
|
23
|
+
loginAttempts.set(ip, { attempts: 1, resetAt: now + LOGIN_WINDOW_MS });
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
record.attempts++;
|
|
27
|
+
return record.attempts <= LOGIN_MAX_ATTEMPTS;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 定期清理过期的速率限制记录
|
|
31
|
+
setInterval(() => {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
for (const [ip, record] of loginAttempts) {
|
|
34
|
+
if (now > record.resetAt) loginAttempts.delete(ip);
|
|
35
|
+
}
|
|
36
|
+
}, 5 * 60 * 1000);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Middleware to verify JWT token for protected API routes
|
|
40
|
+
*/
|
|
41
|
+
function requireAuth(req, res, next) {
|
|
42
|
+
if (CONFIG.skipAuth) {
|
|
43
|
+
req.user = { username: 'dev-user', role: 'admin' };
|
|
44
|
+
return next();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const authHeader = req.headers.authorization;
|
|
48
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
49
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const token = authHeader.replace('Bearer ', '');
|
|
53
|
+
const result = verifyToken(token);
|
|
54
|
+
|
|
55
|
+
if (!result.valid) {
|
|
56
|
+
return res.status(401).json({ error: 'Invalid or expired token' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Sliding renewal: if the token is in the last `jwtRenewThresholdMs` of its
|
|
60
|
+
// life, mint a fresh one and surface it to the client via X-New-Token. The
|
|
61
|
+
// browser fetch wrapper picks this up and swaps localStorage transparently.
|
|
62
|
+
// Skip renewal for non-session tokens (temp/totp/totp-setup) — those have
|
|
63
|
+
// their own short-lived semantics and must not be promoted to full sessions.
|
|
64
|
+
if (!result.type) {
|
|
65
|
+
const fresh = maybeRenewToken(token, result.exp, result.username);
|
|
66
|
+
if (fresh) {
|
|
67
|
+
res.setHeader('X-New-Token', fresh);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
req.user = { username: result.username, role: result.role === 'admin' ? 'admin' : 'pro' };
|
|
72
|
+
next();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Middleware to require admin role
|
|
77
|
+
*/
|
|
78
|
+
function requireAdmin(req, res, next) {
|
|
79
|
+
if (CONFIG.skipAuth) return next();
|
|
80
|
+
if (req.user.role !== 'admin') {
|
|
81
|
+
return res.status(403).json({ error: 'Admin access required' });
|
|
82
|
+
}
|
|
83
|
+
next();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Read version from version.json (injected at Docker build time)
|
|
87
|
+
const __apiDirname = dirname(fileURLToPath(import.meta.url));
|
|
88
|
+
let serverVersion = 'dev';
|
|
89
|
+
try {
|
|
90
|
+
const versionFile = JSON.parse(readFileSync(join(__apiDirname, '../version.json'), 'utf-8'));
|
|
91
|
+
serverVersion = versionFile.version || 'dev';
|
|
92
|
+
} catch {}
|
|
93
|
+
|
|
94
|
+
// Shared middleware/helpers passed to sub-route modules
|
|
95
|
+
const shared = { requireAuth, requireAdmin, checkRateLimit };
|
|
96
|
+
|
|
97
|
+
export function registerApiRoutes(app) {
|
|
98
|
+
// Version API
|
|
99
|
+
app.get('/api/version', (req, res) => {
|
|
100
|
+
res.json({ version: serverVersion });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Delegate to sub-route modules
|
|
104
|
+
registerAuthRoutes(app, shared);
|
|
105
|
+
registerInvitationRoutes(app, shared);
|
|
106
|
+
registerUserRoutes(app, shared);
|
|
107
|
+
registerSessionRoutes(app, shared);
|
|
108
|
+
registerUploadRoutes(app, shared);
|
|
109
|
+
registerAdminRoutes(app, shared);
|
|
110
|
+
registerExpertRoutes(app, shared);
|
|
111
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
import { CONFIG, isAadEnabled, getUserByUsername } from '../config.js';
|
|
3
|
+
import { generateSessionKey, encodeKey } from '../encryption.js';
|
|
4
|
+
import { userDb, identityDb } from '../database.js';
|
|
5
|
+
import { completeLogin } from './login.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Microsoft OIDC public keys cache
|
|
9
|
+
* Keys are fetched from Microsoft's JWKS endpoint and cached.
|
|
10
|
+
*/
|
|
11
|
+
let _jwksCache = null;
|
|
12
|
+
let _jwksCacheExpiry = 0;
|
|
13
|
+
const JWKS_CACHE_TTL = 3600000; // 1 hour
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fetch Microsoft's OIDC public signing keys (JWKS)
|
|
17
|
+
* @returns {Promise<Array>} Array of JWK key objects
|
|
18
|
+
*/
|
|
19
|
+
async function getSigningKeys() {
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
if (_jwksCache && now < _jwksCacheExpiry) {
|
|
22
|
+
return _jwksCache;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const tenantId = CONFIG.aad.tenantId;
|
|
26
|
+
const jwksUrl = `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`;
|
|
27
|
+
|
|
28
|
+
const response = await fetch(jwksUrl);
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
throw new Error(`Failed to fetch JWKS: ${response.status}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const data = await response.json();
|
|
34
|
+
_jwksCache = data.keys;
|
|
35
|
+
_jwksCacheExpiry = now + JWKS_CACHE_TTL;
|
|
36
|
+
return _jwksCache;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Convert a JWK RSA key to PEM format for jwt.verify()
|
|
41
|
+
* @param {Object} jwk - JWK key object with n (modulus) and e (exponent)
|
|
42
|
+
* @returns {string} PEM-formatted public key
|
|
43
|
+
*/
|
|
44
|
+
function jwkToPem(jwk) {
|
|
45
|
+
// Base64url decode
|
|
46
|
+
const base64UrlToBuffer = (str) => {
|
|
47
|
+
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
48
|
+
while (base64.length % 4 !== 0) base64 += '=';
|
|
49
|
+
return Buffer.from(base64, 'base64');
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const n = base64UrlToBuffer(jwk.n);
|
|
53
|
+
const e = base64UrlToBuffer(jwk.e);
|
|
54
|
+
|
|
55
|
+
// DER encode RSA public key
|
|
56
|
+
const encodeDerLength = (length) => {
|
|
57
|
+
if (length < 0x80) return Buffer.from([length]);
|
|
58
|
+
if (length < 0x100) return Buffer.from([0x81, length]);
|
|
59
|
+
return Buffer.from([0x82, (length >> 8) & 0xff, length & 0xff]);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const encodeDerInteger = (buf) => {
|
|
63
|
+
// Prepend 0x00 if high bit set (to ensure positive integer)
|
|
64
|
+
const needsPad = buf[0] & 0x80;
|
|
65
|
+
const content = needsPad ? Buffer.concat([Buffer.from([0x00]), buf]) : buf;
|
|
66
|
+
return Buffer.concat([Buffer.from([0x02]), encodeDerLength(content.length), content]);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const nDer = encodeDerInteger(n);
|
|
70
|
+
const eDer = encodeDerInteger(e);
|
|
71
|
+
|
|
72
|
+
// SEQUENCE { n INTEGER, e INTEGER }
|
|
73
|
+
const rsaKeyBody = Buffer.concat([nDer, eDer]);
|
|
74
|
+
const rsaKeySeq = Buffer.concat([Buffer.from([0x30]), encodeDerLength(rsaKeyBody.length), rsaKeyBody]);
|
|
75
|
+
|
|
76
|
+
// BIT STRING wrapper
|
|
77
|
+
const bitString = Buffer.concat([Buffer.from([0x03]), encodeDerLength(rsaKeySeq.length + 1), Buffer.from([0x00]), rsaKeySeq]);
|
|
78
|
+
|
|
79
|
+
// AlgorithmIdentifier: OID 1.2.840.113549.1.1.1 (rsaEncryption) + NULL
|
|
80
|
+
const algorithmId = Buffer.from([
|
|
81
|
+
0x30, 0x0d,
|
|
82
|
+
0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01,
|
|
83
|
+
0x05, 0x00
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
// SubjectPublicKeyInfo SEQUENCE
|
|
87
|
+
const spkiBody = Buffer.concat([algorithmId, bitString]);
|
|
88
|
+
const spki = Buffer.concat([Buffer.from([0x30]), encodeDerLength(spkiBody.length), spkiBody]);
|
|
89
|
+
|
|
90
|
+
// PEM encode
|
|
91
|
+
const base64 = spki.toString('base64');
|
|
92
|
+
const lines = base64.match(/.{1,64}/g).join('\n');
|
|
93
|
+
return `-----BEGIN PUBLIC KEY-----\n${lines}\n-----END PUBLIC KEY-----`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Verify a Microsoft id_token
|
|
98
|
+
* @param {string} idToken - The id_token from MSAL.js
|
|
99
|
+
* @returns {Promise<Object>} Decoded token payload with user info
|
|
100
|
+
*/
|
|
101
|
+
async function verifyIdToken(idToken) {
|
|
102
|
+
// Decode header to get kid (key ID)
|
|
103
|
+
const headerB64 = idToken.split('.')[0];
|
|
104
|
+
const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString());
|
|
105
|
+
|
|
106
|
+
if (!header.kid) {
|
|
107
|
+
throw new Error('Token missing kid header');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Find matching signing key
|
|
111
|
+
const keys = await getSigningKeys();
|
|
112
|
+
const signingKey = keys.find(k => k.kid === header.kid);
|
|
113
|
+
if (!signingKey) {
|
|
114
|
+
// Invalidate cache and retry once
|
|
115
|
+
_jwksCache = null;
|
|
116
|
+
const freshKeys = await getSigningKeys();
|
|
117
|
+
const freshKey = freshKeys.find(k => k.kid === header.kid);
|
|
118
|
+
if (!freshKey) {
|
|
119
|
+
throw new Error('No matching signing key found');
|
|
120
|
+
}
|
|
121
|
+
return verifyWithKey(idToken, freshKey);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return verifyWithKey(idToken, signingKey);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Verify token with a specific JWK key
|
|
129
|
+
*/
|
|
130
|
+
function verifyWithKey(idToken, jwk) {
|
|
131
|
+
const pem = jwkToPem(jwk);
|
|
132
|
+
const tenantId = CONFIG.aad.tenantId;
|
|
133
|
+
|
|
134
|
+
const decoded = jwt.verify(idToken, pem, {
|
|
135
|
+
algorithms: ['RS256'],
|
|
136
|
+
audience: CONFIG.aad.clientId,
|
|
137
|
+
issuer: `https://login.microsoftonline.com/${tenantId}/v2.0`
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return decoded;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Handle AAD login: verify id_token, find or create user, return JWT
|
|
145
|
+
* @param {string} idToken - Microsoft id_token from frontend MSAL.js
|
|
146
|
+
* @returns {Promise<Object>} Login result with token, sessionKey, role
|
|
147
|
+
*/
|
|
148
|
+
export async function loginWithAad(idToken) {
|
|
149
|
+
if (!isAadEnabled()) {
|
|
150
|
+
return { success: false, error: 'Azure AD login is not enabled' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!idToken) {
|
|
154
|
+
return { success: false, error: 'id_token is required' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let decoded;
|
|
158
|
+
try {
|
|
159
|
+
decoded = await verifyIdToken(idToken);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error('[AAD] Token verification failed:', err.message);
|
|
162
|
+
return { success: false, error: 'Invalid Microsoft token' };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Extract user info from id_token
|
|
166
|
+
const aadOid = decoded.oid || decoded.sub; // Object ID (unique per user per tenant)
|
|
167
|
+
const email = decoded.preferred_username || decoded.email || decoded.upn;
|
|
168
|
+
const name = decoded.name || email?.split('@')[0] || 'aad-user';
|
|
169
|
+
|
|
170
|
+
if (!aadOid) {
|
|
171
|
+
return { success: false, error: 'Token missing user identifier (oid)' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 1. Try to find existing user by AAD OID
|
|
175
|
+
let user = userDb.getByAadOid(aadOid);
|
|
176
|
+
|
|
177
|
+
// 2. If not found by OID, try to match by email/username (link existing account)
|
|
178
|
+
if (!user && email) {
|
|
179
|
+
const existingByEmail = userDb.getByUsername(email.split('@')[0]);
|
|
180
|
+
if (existingByEmail && existingByEmail.email === email) {
|
|
181
|
+
// Link existing user to AAD
|
|
182
|
+
userDb.updateAadOid(existingByEmail.id, aadOid);
|
|
183
|
+
user = existingByEmail;
|
|
184
|
+
console.log(`[AAD] Linked existing user '${existingByEmail.username}' to AAD OID ${aadOid}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 3. Auto-create user if configured
|
|
189
|
+
if (!user) {
|
|
190
|
+
if (!CONFIG.aad.autoCreateUser) {
|
|
191
|
+
return { success: false, error: 'No matching local account found. Contact your admin.' };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Generate unique username from email or name
|
|
195
|
+
let username = email ? email.split('@')[0] : name;
|
|
196
|
+
// Sanitize username: only allow letters, numbers, hyphens, underscores
|
|
197
|
+
username = username.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
198
|
+
|
|
199
|
+
// Ensure uniqueness
|
|
200
|
+
let candidate = username;
|
|
201
|
+
let suffix = 1;
|
|
202
|
+
while (userDb.getByUsername(candidate)) {
|
|
203
|
+
candidate = `${username}_${suffix++}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const role = CONFIG.aad.defaultRole || 'pro';
|
|
207
|
+
user = userDb.createFromAad(candidate, email, aadOid, role);
|
|
208
|
+
console.log(`[AAD] Auto-created user '${candidate}' (email: ${email}, role: ${role})`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 4. Complete login (same as password login)
|
|
212
|
+
const sessionKey = generateSessionKey();
|
|
213
|
+
const role = user.role === 'admin' ? 'admin' : 'pro';
|
|
214
|
+
|
|
215
|
+
// Update last login
|
|
216
|
+
if (user.id) {
|
|
217
|
+
userDb.updateLogin(user.id);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Mirror this AAD login into the unified user_identities table so the
|
|
221
|
+
// multi-provider binding view works for legacy AAD users too.
|
|
222
|
+
try {
|
|
223
|
+
identityDb.upsert({
|
|
224
|
+
userId: user.id,
|
|
225
|
+
provider: 'microsoft',
|
|
226
|
+
subject: aadOid,
|
|
227
|
+
email,
|
|
228
|
+
displayName: name
|
|
229
|
+
});
|
|
230
|
+
} catch (e) {
|
|
231
|
+
console.warn('[AAD] failed to upsert user_identities row:', e.message);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return completeLogin(user.username, sessionKey, role);
|
|
235
|
+
}
|