linco-connect 1.0.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/README.md +426 -0
- package/bin/linco.js +465 -0
- package/package.json +25 -0
- package/public/index.html +1457 -0
- package/server.js +17 -0
- package/src/agentRunner.js +37 -0
- package/src/agents/claude.js +1 -0
- package/src/agents/codex.js +869 -0
- package/src/attachmentHandler.js +258 -0
- package/src/claudeRunner.js +564 -0
- package/src/config.js +371 -0
- package/src/danger.js +21 -0
- package/src/httpStatic.js +166 -0
- package/src/imConnector.js +488 -0
- package/src/imageHandler.js +38 -0
- package/src/lincoProtocol.js +209 -0
- package/src/localAuth.js +46 -0
- package/src/logger.js +137 -0
- package/src/outgoingAttachmentHandler.js +204 -0
- package/src/protocol.js +17 -0
- package/src/serverApp.js +61 -0
- package/src/session.js +359 -0
- package/src/slashCommands.js +349 -0
- package/src/streamBuffer.js +73 -0
- package/src/wsServer.js +293 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { execFileSync } = require('child_process');
|
|
5
|
+
|
|
6
|
+
const TIMEOUT = 10 * 60 * 1000;
|
|
7
|
+
const CONFIG_DIR = path.join(os.homedir(), '.linco');
|
|
8
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
9
|
+
const DEFAULT_AGENT_WS_URLS = {
|
|
10
|
+
claude: 'wss://chat.ddjf.info/socket/ai/claude',
|
|
11
|
+
codex: 'wss://chat.ddjf.info/socket/ai/codex',
|
|
12
|
+
};
|
|
13
|
+
const DEFAULT_LINCO_WS_URL = DEFAULT_AGENT_WS_URLS.claude;
|
|
14
|
+
function getConfigDir() {
|
|
15
|
+
return stringFromEnv('LINCO_HOME', CONFIG_DIR);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getConfigFile(configDir = getConfigDir()) {
|
|
19
|
+
return path.join(configDir, 'config.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_UNSAFE_ATTACHMENT_EXTENSIONS = [
|
|
23
|
+
'.exe',
|
|
24
|
+
'.msi',
|
|
25
|
+
'.dll',
|
|
26
|
+
'.com',
|
|
27
|
+
'.scr',
|
|
28
|
+
'.bat',
|
|
29
|
+
'.cmd',
|
|
30
|
+
'.ps1',
|
|
31
|
+
'.vbs',
|
|
32
|
+
'.hta',
|
|
33
|
+
'.lnk',
|
|
34
|
+
'.url',
|
|
35
|
+
'.reg',
|
|
36
|
+
'.cpl',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function pathCandidatesFromEnv() {
|
|
40
|
+
return (process.env.PATH || '')
|
|
41
|
+
.split(path.delimiter)
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.flatMap(dir => [
|
|
44
|
+
path.join(dir, 'bash.exe'),
|
|
45
|
+
path.join(dir, '..', 'bin', 'bash.exe'),
|
|
46
|
+
path.join(dir, '..', 'usr', 'bin', 'bash.exe'),
|
|
47
|
+
]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pathCandidatesFromWhere(command) {
|
|
51
|
+
try {
|
|
52
|
+
return execFileSync('where.exe', [command], {
|
|
53
|
+
encoding: 'utf8',
|
|
54
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
55
|
+
windowsHide: true,
|
|
56
|
+
})
|
|
57
|
+
.split(/\r?\n/)
|
|
58
|
+
.map(line => line.trim())
|
|
59
|
+
.filter(Boolean);
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveCommand(command) {
|
|
66
|
+
if (!command) return command;
|
|
67
|
+
if (path.isAbsolute(command) && fs.existsSync(command)) return command;
|
|
68
|
+
if (process.platform !== 'win32') return command;
|
|
69
|
+
|
|
70
|
+
const ext = path.extname(command);
|
|
71
|
+
// On Windows, prefer executable shim variants first — npm installs
|
|
72
|
+
// produce .cmd files that handle spawning Node CLI packages.
|
|
73
|
+
const names = ext
|
|
74
|
+
? [command]
|
|
75
|
+
: [`${command}.cmd`, `${command}.exe`, `${command}.bat`, command];
|
|
76
|
+
for (const name of names) {
|
|
77
|
+
for (const candidate of pathCandidatesFromWhere(name)) {
|
|
78
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return command;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function gitBashCandidates() {
|
|
85
|
+
const programFiles = [process.env.ProgramFiles, process.env['ProgramFiles(x86)']].filter(Boolean);
|
|
86
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
87
|
+
const candidates = [
|
|
88
|
+
...programFiles.flatMap(dir => [
|
|
89
|
+
path.join(dir, 'Git', 'bin', 'bash.exe'),
|
|
90
|
+
path.join(dir, 'Git', 'usr', 'bin', 'bash.exe'),
|
|
91
|
+
]),
|
|
92
|
+
localAppData && path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
|
|
93
|
+
localAppData && path.join(localAppData, 'Programs', 'Git', 'usr', 'bin', 'bash.exe'),
|
|
94
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
95
|
+
'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
|
|
96
|
+
'D:\\Program Files\\Git\\bin\\bash.exe',
|
|
97
|
+
'D:\\Program Files\\Git\\usr\\bin\\bash.exe',
|
|
98
|
+
...pathCandidatesFromEnv(),
|
|
99
|
+
...pathCandidatesFromWhere('bash.exe'),
|
|
100
|
+
...pathCandidatesFromWhere('git-bash.exe')
|
|
101
|
+
.flatMap(file => [
|
|
102
|
+
path.join(path.dirname(file), '..', 'bin', 'bash.exe'),
|
|
103
|
+
path.join(path.dirname(file), '..', 'usr', 'bin', 'bash.exe'),
|
|
104
|
+
]),
|
|
105
|
+
...pathCandidatesFromWhere('git.exe')
|
|
106
|
+
.flatMap(file => [
|
|
107
|
+
path.join(path.dirname(file), 'bash.exe'),
|
|
108
|
+
path.join(path.dirname(file), '..', 'bin', 'bash.exe'),
|
|
109
|
+
path.join(path.dirname(file), '..', 'usr', 'bin', 'bash.exe'),
|
|
110
|
+
]),
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
return [...new Set(candidates.filter(Boolean).map(candidate => path.normalize(candidate)))];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function findGitBash(userConfig = {}) {
|
|
117
|
+
if (process.platform !== 'win32') return null;
|
|
118
|
+
|
|
119
|
+
if (process.env.CLAUDE_CODE_GIT_BASH_PATH) {
|
|
120
|
+
return process.env.CLAUDE_CODE_GIT_BASH_PATH;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (userConfig.gitBashPath) {
|
|
124
|
+
return userConfig.gitBashPath;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const candidate of gitBashCandidates()) {
|
|
128
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ensureDir(dir) {
|
|
135
|
+
if (!fs.existsSync(dir)) {
|
|
136
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function numberFromEnv(name, fallback) {
|
|
141
|
+
const value = Number(process.env[name]);
|
|
142
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function listFromEnv(name, fallback) {
|
|
146
|
+
const value = process.env[name];
|
|
147
|
+
if (!value) return fallback;
|
|
148
|
+
return value
|
|
149
|
+
.split(',')
|
|
150
|
+
.map(item => item.trim().toLowerCase())
|
|
151
|
+
.filter(Boolean)
|
|
152
|
+
.map(item => item.startsWith('.') ? item : `.${item}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseToken(value) {
|
|
156
|
+
if (!value) return { appId: '', appSecret: '' };
|
|
157
|
+
const separator = value.indexOf(':');
|
|
158
|
+
if (separator < 0) return { appId: value.trim(), appSecret: '' };
|
|
159
|
+
return {
|
|
160
|
+
appId: value.slice(0, separator).trim(),
|
|
161
|
+
appSecret: value.slice(separator + 1).trim(),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readUserConfig(configFile = getConfigFile()) {
|
|
166
|
+
if (!fs.existsSync(configFile)) return {};
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
170
|
+
} catch (err) {
|
|
171
|
+
throw new Error(`配置文件读取失败: ${configFile}
|
|
172
|
+
${err.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function saveUserConfig(config, configFile = getConfigFile()) {
|
|
177
|
+
ensureDir(path.dirname(configFile));
|
|
178
|
+
fs.writeFileSync(configFile, `${JSON.stringify(config, null, 2)}
|
|
179
|
+
`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function updateUserConfig(updater, configFile = getConfigFile()) {
|
|
183
|
+
const current = readUserConfig(configFile);
|
|
184
|
+
const next = updater({ ...current }) || current;
|
|
185
|
+
saveUserConfig(next, configFile);
|
|
186
|
+
return next;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function selectedChannelConfig(userConfig) {
|
|
190
|
+
const channel = process.env.LINCO_CHANNEL || userConfig.defaultChannel || 'linco';
|
|
191
|
+
const agentType = process.env.LINCO_AGENT || userConfig.defaultAgent || 'claude';
|
|
192
|
+
const channelConfig = userConfig.channels?.[channel];
|
|
193
|
+
const agentConfig = channelConfig?.agents?.[agentType];
|
|
194
|
+
const account = process.env.LINCO_ACCOUNT || agentConfig?.defaultAccount || 'default';
|
|
195
|
+
const accountConfig = agentConfig?.accounts?.[account];
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
channel,
|
|
199
|
+
agentType,
|
|
200
|
+
account,
|
|
201
|
+
...(accountConfig || {}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function stringFromEnv(name, fallback = '') {
|
|
206
|
+
return process.env[name] || fallback || '';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function booleanFromEnv(name, fallback = false) {
|
|
210
|
+
const value = process.env[name];
|
|
211
|
+
if (value == null || value === '') return !!fallback;
|
|
212
|
+
return ['1', 'true', 'yes', 'on'].includes(String(value).trim().toLowerCase());
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function selectedImEnabled(userConfig, imConfig) {
|
|
216
|
+
return booleanFromEnv('LINCO_IM_ENABLED', imConfig.enabled === true || userConfig.im?.enabled === true);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function localImEnabled(userConfig) {
|
|
220
|
+
return booleanFromEnv('LINCO_LOCAL_IM_ENABLED', userConfig.localWeb?.imEnabled === true);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function hasOwn(object, key) {
|
|
224
|
+
return Object.prototype.hasOwnProperty.call(object || {}, key);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function agentConfigFromChannels(userConfig, agentType) {
|
|
228
|
+
const channels = userConfig.channels || {};
|
|
229
|
+
for (const channelKey of Object.keys(channels)) {
|
|
230
|
+
const agents = channels[channelKey]?.agents || {};
|
|
231
|
+
if (agents[agentType]) return agents[agentType];
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function agentConfig(userConfig, imConfig, agentType, defaults = {}) {
|
|
237
|
+
const configured = userConfig.agents?.[agentType] || {};
|
|
238
|
+
const hasAgentEnabled = hasOwn(configured, 'enabled');
|
|
239
|
+
const hasLegacyEnabled = agentType === 'claude' && (
|
|
240
|
+
hasOwn(imConfig, 'enabled') ||
|
|
241
|
+
hasOwn(userConfig.im, 'enabled') ||
|
|
242
|
+
process.env.LINCO_IM_ENABLED != null
|
|
243
|
+
);
|
|
244
|
+
const legacyEnabled = agentType === 'claude'
|
|
245
|
+
? selectedImEnabled(userConfig, imConfig)
|
|
246
|
+
: false;
|
|
247
|
+
const channelAgentConfig = agentConfigFromChannels(userConfig, agentType);
|
|
248
|
+
const channelAccountEnabled = channelAgentConfig && Object.values(channelAgentConfig.accounts || {}).some(
|
|
249
|
+
account => account.enabled === true
|
|
250
|
+
);
|
|
251
|
+
const defaultEnabled = agentType === 'claude' && !hasAgentEnabled && !hasLegacyEnabled;
|
|
252
|
+
const enabledFallback = hasAgentEnabled ? configured.enabled === true : legacyEnabled || defaultEnabled || channelAccountEnabled;
|
|
253
|
+
const enabled = booleanFromEnv(`LINCO_${agentType.toUpperCase()}_ENABLED`, enabledFallback);
|
|
254
|
+
const binEnv = `LINCO_${agentType.toUpperCase()}_BIN`;
|
|
255
|
+
const wsEnv = `LINCO_${agentType.toUpperCase()}_WS_URL`;
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
type: agentType,
|
|
259
|
+
enabled,
|
|
260
|
+
bin: resolveCommand(stringFromEnv(binEnv, configured.bin || defaults.bin)),
|
|
261
|
+
wsUrl: stringFromEnv(wsEnv, configured.wsUrl || defaults.wsUrl),
|
|
262
|
+
mode: stringFromEnv(`LINCO_${agentType.toUpperCase()}_MODE`, configured.mode || defaults.mode),
|
|
263
|
+
model: stringFromEnv(`LINCO_${agentType.toUpperCase()}_MODEL`, configured.model || defaults.model),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function defaultAccountFromChannels(userConfig, agentType) {
|
|
268
|
+
const channels = userConfig.channels || {};
|
|
269
|
+
for (const channelKey of Object.keys(channels)) {
|
|
270
|
+
const agents = channels[channelKey]?.agents || {};
|
|
271
|
+
const agent = agents[agentType];
|
|
272
|
+
if (agent) {
|
|
273
|
+
const accountKey = agent.defaultAccount || 'default';
|
|
274
|
+
return agent.accounts?.[accountKey] || {};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return {};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function loadConfig(rootDir) {
|
|
281
|
+
const configFile = getConfigFile();
|
|
282
|
+
const userConfig = readUserConfig(configFile);
|
|
283
|
+
const imConfig = selectedChannelConfig(userConfig);
|
|
284
|
+
const gitBashPath = findGitBash(userConfig);
|
|
285
|
+
const gitBashEnv = gitBashPath ? gitBashPath.replace(/\\/g, '/') : null;
|
|
286
|
+
const tokenConfig = parseToken(process.env.LINCO_TOKEN || imConfig.token);
|
|
287
|
+
const lincoHome = stringFromEnv('LINCO_HOME', userConfig.lincoHome || path.join(os.homedir(), '.linco'));
|
|
288
|
+
const sessionsDir = stringFromEnv('LINCO_SESSIONS_DIR', userConfig.sessionsDir || path.join(lincoHome, 'sessions'));
|
|
289
|
+
const accountAgentType = imConfig.agentType;
|
|
290
|
+
const codexAccountConfig = defaultAccountFromChannels(userConfig, 'codex');
|
|
291
|
+
const agents = {
|
|
292
|
+
claude: agentConfig(userConfig, imConfig, 'claude', {
|
|
293
|
+
bin: stringFromEnv('CLAUDE_BIN', userConfig.claudeBin || 'claude'),
|
|
294
|
+
wsUrl: stringFromEnv('LINCO_WS_URL', imConfig.wsUrl || DEFAULT_AGENT_WS_URLS.claude),
|
|
295
|
+
}),
|
|
296
|
+
codex: agentConfig(userConfig, imConfig, 'codex', {
|
|
297
|
+
bin: stringFromEnv('CODEX_BIN', userConfig.codexBin || codexAccountConfig.bin || 'codex'),
|
|
298
|
+
wsUrl: codexAccountConfig.wsUrl || DEFAULT_AGENT_WS_URLS.codex,
|
|
299
|
+
}),
|
|
300
|
+
};
|
|
301
|
+
const accountAgent = agents[accountAgentType];
|
|
302
|
+
const imEnabled = selectedImEnabled(userConfig, imConfig) || (accountAgent && accountAgent.enabled);
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
logLevel: stringFromEnv('LOG_LEVEL', userConfig.logLevel || 'info'),
|
|
306
|
+
port: stringFromEnv('PORT', userConfig.port || 3000),
|
|
307
|
+
host: stringFromEnv('HOST', userConfig.host || '127.0.0.1'),
|
|
308
|
+
lincoHome,
|
|
309
|
+
sessionsDir,
|
|
310
|
+
publicDir: path.join(rootDir, 'public'),
|
|
311
|
+
timeout: TIMEOUT,
|
|
312
|
+
gitBashPath,
|
|
313
|
+
gitBashEnv,
|
|
314
|
+
agents,
|
|
315
|
+
defaultLocalAgent: stringFromEnv('LINCO_LOCAL_AGENT', userConfig.defaultLocalAgent || accountAgentType),
|
|
316
|
+
claudeBin: agents.claude.bin,
|
|
317
|
+
claudeAddRuntimeDir: process.env.CLAUDE_ADD_LINCO_RUNTIME_DIR === '0' ? false : userConfig.claudeAddRuntimeDir !== false,
|
|
318
|
+
systemPrompt: stringFromEnv('CLAUDE_SYSTEM_PROMPT', userConfig.systemPrompt || '请使用用户输入的语言进行回复,并采用markdown格式进行格式化输出'),
|
|
319
|
+
maxWsPayloadBytes: numberFromEnv('MAX_WS_PAYLOAD_BYTES', userConfig.maxWsPayloadBytes || 350 * 1024 * 1024),
|
|
320
|
+
maxAttachmentBytes: numberFromEnv('MAX_ATTACHMENT_BYTES', userConfig.maxAttachmentBytes || 50 * 1024 * 1024),
|
|
321
|
+
maxTotalAttachmentBytes: numberFromEnv('MAX_TOTAL_ATTACHMENT_BYTES', userConfig.maxTotalAttachmentBytes || 250 * 1024 * 1024),
|
|
322
|
+
maxAttachmentCount: numberFromEnv('MAX_ATTACHMENT_COUNT', userConfig.maxAttachmentCount || 50),
|
|
323
|
+
maxMessageQueue: numberFromEnv('MAX_MESSAGE_QUEUE', userConfig.maxMessageQueue || 10),
|
|
324
|
+
attachmentsDirName: stringFromEnv('ATTACHMENTS_DIR_NAME', userConfig.attachmentsDirName || 'attachments'),
|
|
325
|
+
outboxDirName: stringFromEnv('OUTBOX_DIR_NAME', userConfig.outboxDirName || 'outbox'),
|
|
326
|
+
maxOutgoingAttachmentBytes: numberFromEnv('MAX_OUTGOING_ATTACHMENT_BYTES', userConfig.maxOutgoingAttachmentBytes || 50 * 1024 * 1024),
|
|
327
|
+
allowUnsafeAttachments: process.env.ALLOW_UNSAFE_ATTACHMENTS === '1' || userConfig.allowUnsafeAttachments === true,
|
|
328
|
+
unsafeAttachmentExtensions: listFromEnv('UNSAFE_ATTACHMENT_EXTENSIONS', userConfig.unsafeAttachmentExtensions || DEFAULT_UNSAFE_ATTACHMENT_EXTENSIONS),
|
|
329
|
+
gracefulShutdownMs: numberFromEnv('CLAUDE_GRACEFUL_SHUTDOWN_MS', userConfig.gracefulShutdownMs || 3000),
|
|
330
|
+
configFile,
|
|
331
|
+
localWeb: {
|
|
332
|
+
...(userConfig.localWeb || {}),
|
|
333
|
+
imEnabled: localImEnabled(userConfig),
|
|
334
|
+
},
|
|
335
|
+
logsDir: path.join(lincoHome, 'logs'),
|
|
336
|
+
im: {
|
|
337
|
+
enabled: imEnabled,
|
|
338
|
+
channel: imConfig.channel,
|
|
339
|
+
account: imConfig.account,
|
|
340
|
+
agentId: stringFromEnv('LINCO_AGENT_ID', imConfig.agentId || userConfig.im?.agentId || 'main'),
|
|
341
|
+
appId: stringFromEnv('LINCO_APP_ID', tokenConfig.appId || imConfig.appId),
|
|
342
|
+
appSecret: stringFromEnv('LINCO_APP_SECRET', tokenConfig.appSecret || imConfig.appSecret),
|
|
343
|
+
wsUrl: accountAgent ? accountAgent.wsUrl : agents.claude.wsUrl,
|
|
344
|
+
reconnectMinMs: numberFromEnv('LINCO_IM_RECONNECT_MIN_MS', imConfig.reconnectMinMs || userConfig.im?.reconnectMinMs || 1000),
|
|
345
|
+
reconnectMaxMs: numberFromEnv('LINCO_IM_RECONNECT_MAX_MS', imConfig.reconnectMaxMs || userConfig.im?.reconnectMaxMs || 30000),
|
|
346
|
+
heartbeatMs: numberFromEnv('LINCO_IM_HEARTBEAT_MS', imConfig.heartbeatMs || userConfig.im?.heartbeatMs || 30000),
|
|
347
|
+
connectTimeoutMs: numberFromEnv('LINCO_IM_CONNECT_TIMEOUT_MS', imConfig.connectTimeoutMs || userConfig.im?.connectTimeoutMs || 15000),
|
|
348
|
+
idleSessionMs: numberFromEnv('LINCO_IM_IDLE_SESSION_MS', imConfig.idleSessionMs || userConfig.im?.idleSessionMs || 30 * 60 * 1000),
|
|
349
|
+
maxPendingEvents: numberFromEnv('LINCO_IM_MAX_PENDING_EVENTS', imConfig.maxPendingEvents || userConfig.im?.maxPendingEvents || 500),
|
|
350
|
+
allowInsecureWs: booleanFromEnv('LINCO_IM_ALLOW_INSECURE_WS', imConfig.allowInsecureWs === true || userConfig.im?.allowInsecureWs === true),
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
module.exports = {
|
|
356
|
+
CONFIG_DIR,
|
|
357
|
+
CONFIG_FILE,
|
|
358
|
+
DEFAULT_AGENT_WS_URLS,
|
|
359
|
+
DEFAULT_LINCO_WS_URL,
|
|
360
|
+
DEFAULT_UNSAFE_ATTACHMENT_EXTENSIONS,
|
|
361
|
+
ensureDir,
|
|
362
|
+
findGitBash,
|
|
363
|
+
getConfigDir,
|
|
364
|
+
getConfigFile,
|
|
365
|
+
loadConfig,
|
|
366
|
+
parseToken,
|
|
367
|
+
readUserConfig,
|
|
368
|
+
resolveCommand,
|
|
369
|
+
saveUserConfig,
|
|
370
|
+
updateUserConfig,
|
|
371
|
+
};
|
package/src/danger.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const DANGEROUS_PATTERNS = [
|
|
2
|
+
/\brm\s+-rf?\b/i,
|
|
3
|
+
/\bsudo\b/i,
|
|
4
|
+
/\bchmod\s+777\b/i,
|
|
5
|
+
/\bcurl.*\|.*sh\b/i,
|
|
6
|
+
/\bwget.*-O.*\|\s*sh\b/i,
|
|
7
|
+
/\b>\/dev\/sd[a-z]\b/i,
|
|
8
|
+
/\bdd\s+if=/i,
|
|
9
|
+
/\bmkfs\./i,
|
|
10
|
+
/\b:(){ :|:& };:\b/i,
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function isDangerousCommand(text) {
|
|
14
|
+
if (typeof text !== 'string') return false;
|
|
15
|
+
return DANGEROUS_PATTERNS.some(pattern => pattern.test(text));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = {
|
|
19
|
+
DANGEROUS_PATTERNS,
|
|
20
|
+
isDangerousCommand,
|
|
21
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { getOutgoingAttachment } = require('./outgoingAttachmentHandler');
|
|
5
|
+
const { isLocalRequestAuthorized } = require('./localAuth');
|
|
6
|
+
|
|
7
|
+
const mimeTypes = {
|
|
8
|
+
'.html': 'text/html; charset=utf-8',
|
|
9
|
+
'.js': 'application/javascript',
|
|
10
|
+
'.css': 'text/css',
|
|
11
|
+
'.json': 'application/json',
|
|
12
|
+
'.svg': 'image/svg+xml',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function createStaticServer(config) {
|
|
16
|
+
return http.createServer((req, res) => {
|
|
17
|
+
const url = new URL(req.url, 'http://localhost');
|
|
18
|
+
|
|
19
|
+
if (!isLocalRequestAuthorized(req, config, url)) {
|
|
20
|
+
return rejectUnauthorized(res);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (url.pathname === '/api/client-config') {
|
|
24
|
+
return handleClientConfigRequest(req, res, config);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (url.pathname.startsWith('/api/session/')) {
|
|
28
|
+
return handleApiRequest(url, res, config);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const requestPath = url.pathname === '/' ? '/index.html' : url.pathname;
|
|
32
|
+
let relativePath;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
relativePath = decodeURIComponent(requestPath).replace(/^\/+/, '');
|
|
36
|
+
} catch {
|
|
37
|
+
res.writeHead(400);
|
|
38
|
+
return res.end('Bad request');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const fullPath = path.resolve(config.publicDir, relativePath);
|
|
42
|
+
|
|
43
|
+
if (!fullPath.startsWith(path.resolve(config.publicDir) + path.sep)) {
|
|
44
|
+
res.writeHead(403);
|
|
45
|
+
return res.end('Forbidden');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ext = path.extname(fullPath);
|
|
49
|
+
|
|
50
|
+
fs.readFile(fullPath, (err, data) => {
|
|
51
|
+
if (err) {
|
|
52
|
+
res.writeHead(404);
|
|
53
|
+
return res.end('Not found');
|
|
54
|
+
}
|
|
55
|
+
res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'text/plain' });
|
|
56
|
+
res.end(data);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function rejectUnauthorized(res) {
|
|
62
|
+
res.writeHead(401, {
|
|
63
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
64
|
+
'Cache-Control': 'no-store',
|
|
65
|
+
});
|
|
66
|
+
res.end('未授权访问本地测试页,请使用 linco start 输出的本地测试地址打开。');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function handleClientConfigRequest(req, res, config) {
|
|
70
|
+
let wsUrl;
|
|
71
|
+
try {
|
|
72
|
+
wsUrl = buildClientWebSocketUrl(req, config);
|
|
73
|
+
} catch {
|
|
74
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
75
|
+
return res.end(JSON.stringify({ error: 'Invalid WebSocket configuration' }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const body = JSON.stringify({
|
|
79
|
+
wsUrl,
|
|
80
|
+
account: config.im?.account || '',
|
|
81
|
+
configured: Boolean(config.im?.appId && config.im?.appSecret),
|
|
82
|
+
defaultLocalAgent: config.defaultLocalAgent || 'claude',
|
|
83
|
+
localImEnabled: config.localWeb?.imEnabled === true,
|
|
84
|
+
agents: localAgentOptions(config),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
res.writeHead(200, {
|
|
88
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
89
|
+
'Cache-Control': 'no-store',
|
|
90
|
+
});
|
|
91
|
+
res.end(body);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildClientWebSocketUrl(req, config) {
|
|
95
|
+
const wsUrl = new URL(defaultWebSocketUrl(req));
|
|
96
|
+
|
|
97
|
+
if (config.localWeb?.token) wsUrl.searchParams.set('localToken', config.localWeb.token);
|
|
98
|
+
|
|
99
|
+
return wsUrl.toString();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function localAgentOptions(config) {
|
|
103
|
+
const agents = config.agents || {};
|
|
104
|
+
return ['claude', 'codex']
|
|
105
|
+
.filter(type => agents[type])
|
|
106
|
+
.map(type => ({
|
|
107
|
+
type,
|
|
108
|
+
label: type === 'claude' ? 'Claude Code' : type === 'codex' ? 'Codex' : type,
|
|
109
|
+
enabled: agents[type]?.enabled === true,
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function defaultWebSocketUrl(req) {
|
|
114
|
+
const forwardedProto = req.headers['x-forwarded-proto'];
|
|
115
|
+
const requestProto = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
|
|
116
|
+
const firstProto = String(requestProto || '').split(',')[0].trim();
|
|
117
|
+
const protocol = firstProto === 'https' ? 'wss:' : 'ws:';
|
|
118
|
+
const host = req.headers.host || 'localhost';
|
|
119
|
+
return `${protocol}//${host}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function handleApiRequest(url, res, config) {
|
|
123
|
+
const match = url.pathname.match(/^\/api\/session\/([^/]+)\/outgoing\/([^/]+)$/);
|
|
124
|
+
if (!match) {
|
|
125
|
+
res.writeHead(404);
|
|
126
|
+
return res.end('Not found');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const sessionId = safeDecode(match[1]);
|
|
130
|
+
const fileId = safeDecode(match[2]);
|
|
131
|
+
if (!sessionId || !fileId || sessionId.length > 256 || !config.activeSessions) {
|
|
132
|
+
res.writeHead(404);
|
|
133
|
+
return res.end('Not found');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const file = getOutgoingAttachment(config.activeSessions, sessionId, fileId, config, url.searchParams.get('agentType'));
|
|
137
|
+
if (!file) {
|
|
138
|
+
res.writeHead(404);
|
|
139
|
+
return res.end('Not found');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const disposition = file.kind === 'image' ? 'inline' : 'attachment';
|
|
143
|
+
res.writeHead(200, {
|
|
144
|
+
'Content-Type': file.mimeType || 'application/octet-stream',
|
|
145
|
+
'Content-Length': file.size,
|
|
146
|
+
'Content-Disposition': `${disposition}; filename*=UTF-8''${encodeRFC5987(file.name)}`,
|
|
147
|
+
'X-Content-Type-Options': 'nosniff',
|
|
148
|
+
});
|
|
149
|
+
fs.createReadStream(file.path).pipe(res);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function safeDecode(value) {
|
|
153
|
+
try {
|
|
154
|
+
return decodeURIComponent(value);
|
|
155
|
+
} catch {
|
|
156
|
+
return '';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function encodeRFC5987(value) {
|
|
161
|
+
return encodeURIComponent(value).replace(/['()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
createStaticServer,
|
|
166
|
+
};
|