wtt-connect 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/README.md +295 -0
- package/bin/wtt-connect.js +7 -0
- package/package.json +32 -0
- package/scripts/install-agent.sh +4 -0
- package/scripts/install-launchd.sh +51 -0
- package/scripts/install-systemd-user.sh +189 -0
- package/scripts/uninstall-launchd.sh +18 -0
- package/scripts/uninstall-systemd-user.sh +42 -0
- package/src/adapter-registry.js +58 -0
- package/src/adapters/acp.js +196 -0
- package/src/adapters/claude-code.js +161 -0
- package/src/adapters/codex.js +88 -0
- package/src/adapters/generic-cli.js +258 -0
- package/src/adapters/index.js +28 -0
- package/src/adapters/openclaw.js +32 -0
- package/src/artifacts.js +68 -0
- package/src/attachments.js +181 -0
- package/src/config.js +118 -0
- package/src/env.js +42 -0
- package/src/events.js +23 -0
- package/src/logger.js +18 -0
- package/src/main.js +143 -0
- package/src/mime.js +12 -0
- package/src/permissions.js +28 -0
- package/src/runner.js +562 -0
- package/src/runtime-info.js +71 -0
- package/src/service-manager.js +510 -0
- package/src/session-manager.js +31 -0
- package/src/setup.js +71 -0
- package/src/shell-runner.js +184 -0
- package/src/smoke.js +99 -0
- package/src/store.js +58 -0
- package/src/stt.js +146 -0
- package/src/terminal-session.js +152 -0
- package/src/tts.js +40 -0
- package/src/wtt-api.js +79 -0
- package/src/wtt-client.js +144 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const READONLY_COMMANDS = new Set([
|
|
6
|
+
'pwd', 'ls', 'find', 'cat', 'sed', 'grep', 'egrep', 'fgrep', 'rg',
|
|
7
|
+
'head', 'tail', 'wc', 'du', 'df', 'stat', 'file', 'tree',
|
|
8
|
+
'git', 'node', 'npm', 'python', 'python3', 'which', 'whoami',
|
|
9
|
+
'hostname', 'date', 'env', 'printenv',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const READONLY_GIT_SUBCOMMANDS = new Set([
|
|
13
|
+
'', '--version', '-v', 'status', 'branch', 'rev-parse', 'log', 'show',
|
|
14
|
+
'diff', 'describe', 'remote', 'ls-files', 'ls-tree', 'grep', 'config',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const READONLY_NPM_SUBCOMMANDS = new Set(['', '--version', '-v', 'version', 'view', 'list', 'ls', 'root', 'prefix']);
|
|
18
|
+
|
|
19
|
+
const BLOCKED_PATTERNS = [
|
|
20
|
+
/\brm\s+-[^\n;|&]*r/i,
|
|
21
|
+
/\bsudo\b/i,
|
|
22
|
+
/\bsu\s+/i,
|
|
23
|
+
/\bchmod\s+777\b/i,
|
|
24
|
+
/\bchown\b/i,
|
|
25
|
+
/\bmkfs\b/i,
|
|
26
|
+
/\bdd\s+if=/i,
|
|
27
|
+
/\bshutdown\b/i,
|
|
28
|
+
/\breboot\b/i,
|
|
29
|
+
/\bcurl\b[\s\S]*\|\s*(sh|bash)\b/i,
|
|
30
|
+
/\bwget\b[\s\S]*\|\s*(sh|bash)\b/i,
|
|
31
|
+
/:\s*\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}/,
|
|
32
|
+
/`|\$\(/,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export async function runShellCommand(config, request) {
|
|
36
|
+
if (!config.enableShell) {
|
|
37
|
+
throw new Error('wtt-connect shell is disabled');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const command = String(request.command || '').trim();
|
|
41
|
+
if (!command) throw new Error('command is required');
|
|
42
|
+
validateCommand(command, config.shellMode);
|
|
43
|
+
|
|
44
|
+
const baseDir = resolveDir(config.workDir);
|
|
45
|
+
const cwd = resolveCwd(baseDir, request.cwd);
|
|
46
|
+
const timeoutMs = Math.max(1000, Math.min(Number(request.timeout_ms || 0) || config.shellTimeoutSeconds * 1000, config.shellTimeoutSeconds * 1000));
|
|
47
|
+
const maxOutput = Math.max(1000, Number(config.shellMaxOutputChars || 20000));
|
|
48
|
+
const startedAt = Date.now();
|
|
49
|
+
|
|
50
|
+
return await new Promise((resolve) => {
|
|
51
|
+
const child = spawn(command, {
|
|
52
|
+
cwd,
|
|
53
|
+
shell: process.env.SHELL || '/bin/sh',
|
|
54
|
+
env: process.env,
|
|
55
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
let stdout = '';
|
|
59
|
+
let stderr = '';
|
|
60
|
+
let killed = false;
|
|
61
|
+
const append = (current, chunk) => {
|
|
62
|
+
const next = current + chunk.toString('utf8');
|
|
63
|
+
if (next.length <= maxOutput) return next;
|
|
64
|
+
return next.slice(0, Math.floor(maxOutput / 2)) + '\n...[output truncated]...\n' + next.slice(-Math.floor(maxOutput / 2));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
killed = true;
|
|
69
|
+
child.kill('SIGTERM');
|
|
70
|
+
setTimeout(() => {
|
|
71
|
+
if (!child.killed) child.kill('SIGKILL');
|
|
72
|
+
}, 1000);
|
|
73
|
+
}, timeoutMs);
|
|
74
|
+
|
|
75
|
+
child.stdout.on('data', (chunk) => { stdout = append(stdout, chunk); });
|
|
76
|
+
child.stderr.on('data', (chunk) => { stderr = append(stderr, chunk); });
|
|
77
|
+
child.on('error', (err) => {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
resolve({
|
|
80
|
+
command,
|
|
81
|
+
cwd,
|
|
82
|
+
exit_code: 127,
|
|
83
|
+
stdout,
|
|
84
|
+
stderr: stderr || err.message,
|
|
85
|
+
duration_ms: Date.now() - startedAt,
|
|
86
|
+
timed_out: false,
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
child.on('close', (code, signal) => {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
resolve({
|
|
92
|
+
command,
|
|
93
|
+
cwd,
|
|
94
|
+
exit_code: killed ? 124 : (code ?? 1),
|
|
95
|
+
signal: signal || '',
|
|
96
|
+
stdout,
|
|
97
|
+
stderr: killed ? `${stderr}${stderr ? '\n' : ''}command timed out after ${timeoutMs}ms` : stderr,
|
|
98
|
+
duration_ms: Date.now() - startedAt,
|
|
99
|
+
timed_out: killed,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validateCommand(command, mode) {
|
|
106
|
+
const effectiveMode = String(mode || 'unsafe').toLowerCase();
|
|
107
|
+
if (effectiveMode === 'off') throw new Error('wtt-connect shell mode is off');
|
|
108
|
+
if (effectiveMode === 'unsafe') return;
|
|
109
|
+
|
|
110
|
+
for (const pattern of BLOCKED_PATTERNS) {
|
|
111
|
+
if (pattern.test(command)) throw new Error('command is blocked by wtt-connect shell policy');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (effectiveMode === 'readonly' && hasWriteOperator(command)) {
|
|
115
|
+
throw new Error('write/redirection operators are blocked in readonly shell mode');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const segment of commandSegments(command)) {
|
|
119
|
+
validateReadonlySegment(segment, effectiveMode);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function commandSegments(command) {
|
|
124
|
+
return String(command)
|
|
125
|
+
.split(/&&|\|\||;|\|/)
|
|
126
|
+
.map((part) => part.trim())
|
|
127
|
+
.filter(Boolean);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function validateReadonlySegment(segment, mode) {
|
|
131
|
+
const first = firstCommandToken(segment);
|
|
132
|
+
if (!READONLY_COMMANDS.has(first)) {
|
|
133
|
+
throw new Error(`command '${first || segment}' is not allowed in ${mode} shell mode`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const tokens = segmentTokens(segment);
|
|
137
|
+
const subcommand = tokens.find((token, idx) => idx > 0 && !token.startsWith('-')) || tokens[1] || '';
|
|
138
|
+
if (first === 'git' && !READONLY_GIT_SUBCOMMANDS.has(subcommand)) {
|
|
139
|
+
throw new Error(`git ${subcommand} is not allowed in readonly shell mode`);
|
|
140
|
+
}
|
|
141
|
+
if (first === 'npm' && !READONLY_NPM_SUBCOMMANDS.has(subcommand)) {
|
|
142
|
+
throw new Error(`npm ${subcommand} is not allowed in readonly shell mode`);
|
|
143
|
+
}
|
|
144
|
+
if ((first === 'node' || first === 'python' || first === 'python3') && subcommand && !subcommand.startsWith('-')) {
|
|
145
|
+
throw new Error(`${first} scripts are blocked in readonly shell mode`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function firstCommandToken(command) {
|
|
150
|
+
const trimmed = command.trim().replace(/^\s*(time|command|env)\s+/, '');
|
|
151
|
+
return (trimmed.match(/^([A-Za-z0-9_./-]+)/)?.[1] || '').split('/').pop() || '';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function segmentTokens(command) {
|
|
155
|
+
return command.trim().replace(/^\s*(time|command|env)\s+/, '').split(/\s+/).filter(Boolean).map((token) => token.split('/').pop() || token);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hasWriteOperator(command) {
|
|
159
|
+
return /(^|[^<])>(?!>)|>>|\btee\b|\bxargs\b|\bmv\b|\bcp\b|\btouch\b|\bmkdir\b|\brmdir\b|\binstall\b|\bpatch\b|\bnpm\s+(install|i|add|run|publish|pack|link|unlink|ci|update|dedupe)\b|\bgit\s+(add|commit|checkout|switch|reset|restore|clean|pull|push|merge|rebase|cherry-pick|stash|tag|worktree)\b/i.test(command);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function resolveDir(dir) {
|
|
163
|
+
const resolved = path.resolve(String(dir || process.cwd()));
|
|
164
|
+
try {
|
|
165
|
+
return fs.realpathSync(resolved);
|
|
166
|
+
} catch {
|
|
167
|
+
return resolved;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function resolveCwd(baseDir, requested) {
|
|
172
|
+
if (!requested) return baseDir;
|
|
173
|
+
const joined = path.resolve(baseDir, String(requested));
|
|
174
|
+
let resolved = joined;
|
|
175
|
+
try {
|
|
176
|
+
resolved = fs.realpathSync(joined);
|
|
177
|
+
} catch {
|
|
178
|
+
// Directory may not exist; keep resolved path and let spawn fail.
|
|
179
|
+
}
|
|
180
|
+
if (resolved !== baseDir && !resolved.startsWith(`${baseDir}${path.sep}`)) {
|
|
181
|
+
throw new Error('cwd must stay inside agent workdir');
|
|
182
|
+
}
|
|
183
|
+
return resolved;
|
|
184
|
+
}
|
package/src/smoke.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { WTTApi } from './wtt-api.js';
|
|
2
|
+
|
|
3
|
+
export async function smokeTask(config, argv = {}) {
|
|
4
|
+
if (!config.agentId) throw new Error('WTT_AGENT_ID is required');
|
|
5
|
+
const expected = argv.expected || 'WTT_CONNECT_TASK_READY';
|
|
6
|
+
const api = new WTTApi(config);
|
|
7
|
+
const task = await api.createTask({
|
|
8
|
+
title: 'wtt-connect task smoke',
|
|
9
|
+
description: argv.prompt || `请只回复 ${expected}。不要修改文件,不要运行命令。`,
|
|
10
|
+
task_type: 'code', priority: 'P3', status: 'todo',
|
|
11
|
+
owner_agent_id: config.agentId, runner_agent_id: config.agentId,
|
|
12
|
+
exec_mode: config.adapter || 'codex',
|
|
13
|
+
});
|
|
14
|
+
const taskId = task.id;
|
|
15
|
+
console.log(`created task=${taskId} topic=${task.topic_id || ''}`);
|
|
16
|
+
await api.runTask(taskId, { runner_agent_id: config.agentId, exec_mode: config.adapter || 'codex', note: 'wtt-connect smoke' });
|
|
17
|
+
const deadline = Date.now() + Number(argv.timeout || 180000);
|
|
18
|
+
let last = '';
|
|
19
|
+
while (Date.now() < deadline) {
|
|
20
|
+
await sleep(3000);
|
|
21
|
+
const payload = await api.getTask(taskId);
|
|
22
|
+
const status = payload?.status || '';
|
|
23
|
+
if (status !== last) { console.log(`status=${status}`); last = status; }
|
|
24
|
+
const output = payload?.output || payload?.summary || payload?.notes || '';
|
|
25
|
+
if (['review', 'done', 'approved'].includes(status)) {
|
|
26
|
+
console.log(output);
|
|
27
|
+
if (!String(output).includes(expected)) throw new Error(`expected ${expected} not found`);
|
|
28
|
+
console.log(`OK task=${taskId}`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`timeout waiting for task smoke`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function smokeChat(config, argv = {}) {
|
|
36
|
+
const senderId = argv.senderAgentId || process.env.WTT_SMOKE_SENDER_AGENT_ID;
|
|
37
|
+
const senderToken = argv.senderToken || process.env.WTT_SMOKE_SENDER_TOKEN;
|
|
38
|
+
if (!senderId || !senderToken) throw new Error('WTT_SMOKE_SENDER_AGENT_ID/TOKEN required for chat smoke');
|
|
39
|
+
if (!config.agentId) throw new Error('WTT_AGENT_ID is required');
|
|
40
|
+
const expected = argv.expected || 'WTT_CONNECT_CHAT_READY';
|
|
41
|
+
const ws = new WebSocket(wsUrl(config.wttBaseUrl, senderId));
|
|
42
|
+
await onceOpen(ws, 15000);
|
|
43
|
+
await send(ws, { action: 'auth', request_id: rid('auth'), token: senderToken });
|
|
44
|
+
const topic = await action(ws, 'p2p', { target_agent_id: config.agentId, content: argv.message || `请只回复 ${expected}` });
|
|
45
|
+
const topicId = topic.topic_id || topic.id || topic.topic?.id;
|
|
46
|
+
console.log(`sent p2p topic=${topicId}`);
|
|
47
|
+
const deadline = Date.now() + Number(argv.timeout || 180000);
|
|
48
|
+
while (Date.now() < deadline) {
|
|
49
|
+
const raw = await recv(ws, Math.min(30000, Math.max(1000, deadline - Date.now())));
|
|
50
|
+
if (raw === 'pong') continue;
|
|
51
|
+
const msg = JSON.parse(raw);
|
|
52
|
+
if (msg.type !== 'new_message') continue;
|
|
53
|
+
const m = msg.message || {};
|
|
54
|
+
const content = String(m.content || '');
|
|
55
|
+
if (m.sender_id === config.agentId && content.includes(expected)) {
|
|
56
|
+
console.log(content);
|
|
57
|
+
console.log('OK');
|
|
58
|
+
ws.close();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
ws.close();
|
|
63
|
+
throw new Error('timeout waiting for chat smoke');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function wsUrl(base, agentId) {
|
|
67
|
+
const b = String(base).replace(/\/$/, '').replace(/^https:/, 'wss:').replace(/^http:/, 'ws:');
|
|
68
|
+
return `${b}/ws/${agentId}`;
|
|
69
|
+
}
|
|
70
|
+
function rid(prefix) { return `${prefix}-${crypto.randomUUID().slice(0, 8)}`; }
|
|
71
|
+
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
72
|
+
function send(ws, obj) { ws.send(JSON.stringify(obj)); }
|
|
73
|
+
function recv(ws, timeout) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const t = setTimeout(() => reject(new Error('websocket recv timeout')), timeout);
|
|
76
|
+
ws.addEventListener('message', (ev) => { clearTimeout(t); resolve(String(ev.data)); }, { once: true });
|
|
77
|
+
ws.addEventListener('error', () => { clearTimeout(t); reject(new Error('websocket error')); }, { once: true });
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function onceOpen(ws, timeout) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const t = setTimeout(() => reject(new Error('websocket open timeout')), timeout);
|
|
83
|
+
ws.addEventListener('open', () => { clearTimeout(t); resolve(); }, { once: true });
|
|
84
|
+
ws.addEventListener('error', () => { clearTimeout(t); reject(new Error('websocket open error')); }, { once: true });
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async function action(ws, name, payload) {
|
|
88
|
+
const requestId = rid(name);
|
|
89
|
+
send(ws, { action: name, request_id: requestId, ...payload });
|
|
90
|
+
while (true) {
|
|
91
|
+
const raw = await recv(ws, 20000);
|
|
92
|
+
if (raw === 'pong') continue;
|
|
93
|
+
const msg = JSON.parse(raw);
|
|
94
|
+
if (msg.type === 'action_result' && msg.request_id === requestId) {
|
|
95
|
+
if (!msg.ok) throw new Error(String(msg.error || 'action failed'));
|
|
96
|
+
return msg.data;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export class DurableStore {
|
|
5
|
+
constructor(file) {
|
|
6
|
+
this.file = file;
|
|
7
|
+
this.data = { version: 1, sessions: {}, seen: {}, artifacts: [] };
|
|
8
|
+
this.loaded = false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
load() {
|
|
12
|
+
if (this.loaded) return this;
|
|
13
|
+
this.loaded = true;
|
|
14
|
+
try {
|
|
15
|
+
if (fs.existsSync(this.file)) this.data = { ...this.data, ...JSON.parse(fs.readFileSync(this.file, 'utf8')) };
|
|
16
|
+
} catch {
|
|
17
|
+
this.data = { version: 1, sessions: {}, seen: {}, artifacts: [] };
|
|
18
|
+
}
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
save() {
|
|
23
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
24
|
+
const tmp = `${this.file}.tmp`;
|
|
25
|
+
fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
|
|
26
|
+
fs.renameSync(tmp, this.file);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
getSession(sessionKey) {
|
|
30
|
+
this.load();
|
|
31
|
+
return this.data.sessions[sessionKey] || {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
patchSession(sessionKey, patch) {
|
|
35
|
+
this.load();
|
|
36
|
+
this.data.sessions[sessionKey] = { ...(this.data.sessions[sessionKey] || {}), ...patch, updatedAt: new Date().toISOString() };
|
|
37
|
+
this.save();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
hasSeen(id, ttlMs = 24 * 3600_000) {
|
|
41
|
+
this.load();
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
for (const [k, ts] of Object.entries(this.data.seen || {})) {
|
|
44
|
+
if (now - Number(ts) > ttlMs) delete this.data.seen[k];
|
|
45
|
+
}
|
|
46
|
+
if (this.data.seen[id]) return true;
|
|
47
|
+
this.data.seen[id] = now;
|
|
48
|
+
this.save();
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
addArtifact(item) {
|
|
53
|
+
this.load();
|
|
54
|
+
this.data.artifacts.push({ ...item, createdAt: new Date().toISOString() });
|
|
55
|
+
this.data.artifacts = this.data.artifacts.slice(-500);
|
|
56
|
+
this.save();
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/stt.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
export class STTManager {
|
|
7
|
+
constructor(config) {
|
|
8
|
+
this.config = config;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async transcribe(file) {
|
|
12
|
+
const provider = normalizeProvider(this.config.stt.provider);
|
|
13
|
+
if (!provider || provider === 'none') return '';
|
|
14
|
+
if (provider === 'command') return runCommandTemplate(this.config.stt.command, file.path);
|
|
15
|
+
if (provider === 'openai') return transcribeWithOpenAI(this.config.stt, file);
|
|
16
|
+
throw new Error(`unsupported STT provider: ${this.config.stt.provider}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async transcribeAll(files = []) {
|
|
20
|
+
const audio = files.filter((f) => String(f.mimeType || '').startsWith('audio/'));
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const f of audio) {
|
|
23
|
+
const text = (await this.transcribe(f)).trim();
|
|
24
|
+
if (text) out.push({ file: f, text });
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeProvider(provider) {
|
|
31
|
+
const p = String(provider || '').trim().toLowerCase();
|
|
32
|
+
if (['openai', 'whisper', 'openai-whisper'].includes(p)) return 'openai';
|
|
33
|
+
return p;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function transcribeWithOpenAI(sttConfig, file) {
|
|
37
|
+
const apiKey = sttConfig.openaiApiKey || process.env.OPENAI_API_KEY;
|
|
38
|
+
if (!apiKey) throw new Error('OPENAI_API_KEY is required for OpenAI STT provider');
|
|
39
|
+
const base = String(sttConfig.openaiBaseUrl || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1').replace(/\/$/, '');
|
|
40
|
+
const model = sttConfig.openaiModel || 'whisper-1';
|
|
41
|
+
const timeoutMs = Number(sttConfig.timeoutMs || 120000);
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
44
|
+
try {
|
|
45
|
+
const bytes = await fs.readFile(file.path);
|
|
46
|
+
const form = new FormData();
|
|
47
|
+
form.append('file', new Blob([bytes], { type: file.mimeType || 'application/octet-stream' }), file.name || path.basename(file.path));
|
|
48
|
+
form.append('model', model);
|
|
49
|
+
if (sttConfig.language) form.append('language', sttConfig.language);
|
|
50
|
+
if (sttConfig.prompt) form.append('prompt', sttConfig.prompt);
|
|
51
|
+
if (sttConfig.responseFormat) form.append('response_format', sttConfig.responseFormat);
|
|
52
|
+
const transport = String(sttConfig.openaiTransport || 'auto').toLowerCase();
|
|
53
|
+
if (transport === 'curl') return parseOpenAITranscript(await runOpenAICurl({ sttConfig, file, apiKey, base, model }));
|
|
54
|
+
try {
|
|
55
|
+
const response = await fetch(`${base}/audio/transcriptions`, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
58
|
+
body: form,
|
|
59
|
+
signal: controller.signal,
|
|
60
|
+
});
|
|
61
|
+
const raw = await response.text();
|
|
62
|
+
if (!response.ok) throw new Error(`OpenAI STT failed: ${response.status} ${sanitizeProviderError(raw)}`);
|
|
63
|
+
return parseOpenAITranscript(raw);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (transport === 'fetch') throw err;
|
|
66
|
+
return parseOpenAITranscript(await runOpenAICurl({ sttConfig, file, apiKey, base, model }));
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseOpenAITranscript(raw) {
|
|
74
|
+
try {
|
|
75
|
+
const json = JSON.parse(raw);
|
|
76
|
+
return String(json.text || json.transcript || '').trim();
|
|
77
|
+
} catch {
|
|
78
|
+
return String(raw || '').trim();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function runOpenAICurl({ sttConfig, file, apiKey, base, model }) {
|
|
83
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wtt-connect-stt-'));
|
|
84
|
+
const cfg = path.join(tmpDir, 'curl.conf');
|
|
85
|
+
const lines = [
|
|
86
|
+
`url = ${curlQuote(`${base}/audio/transcriptions`)}`,
|
|
87
|
+
'request = "POST"',
|
|
88
|
+
'silent',
|
|
89
|
+
'show-error',
|
|
90
|
+
'fail-with-body',
|
|
91
|
+
`max-time = ${Math.ceil(Number(sttConfig.timeoutMs || 120000) / 1000)}`,
|
|
92
|
+
`header = ${curlQuote(`Authorization: Bearer ${apiKey}`)}`,
|
|
93
|
+
`form = ${curlQuote(`file=@${file.path};type=${file.mimeType || 'application/octet-stream'};filename=${file.name || path.basename(file.path)}`)}`,
|
|
94
|
+
`form = ${curlQuote(`model=${model}`)}`,
|
|
95
|
+
];
|
|
96
|
+
if (sttConfig.language) lines.push(`form = ${curlQuote(`language=${sttConfig.language}`)}`);
|
|
97
|
+
if (sttConfig.prompt) lines.push(`form = ${curlQuote(`prompt=${sttConfig.prompt}`)}`);
|
|
98
|
+
if (sttConfig.responseFormat) lines.push(`form = ${curlQuote(`response_format=${sttConfig.responseFormat}`)}`);
|
|
99
|
+
await fs.writeFile(cfg, `${lines.join('\n')}\n`, { mode: 0o600 });
|
|
100
|
+
try {
|
|
101
|
+
return await runCommand('curl', ['--config', cfg]);
|
|
102
|
+
} finally {
|
|
103
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function curlQuote(value) {
|
|
108
|
+
return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', ' ')}"`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function runCommand(command, args) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
114
|
+
let stdout = '';
|
|
115
|
+
let stderr = '';
|
|
116
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
117
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
118
|
+
child.on('error', reject);
|
|
119
|
+
child.on('close', (code) => code === 0 ? resolve(stdout) : reject(new Error(`${command} exited ${code}: ${sanitizeProviderError(`${stderr}\n${stdout}`)}`)));
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function runCommandTemplate(template, file) {
|
|
124
|
+
if (!template) throw new Error('WTT_CONNECT_STT_COMMAND is required for command STT provider');
|
|
125
|
+
const command = template.replaceAll('{file}', shellQuote(file));
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const child = spawn('/bin/sh', ['-lc', command], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
128
|
+
let stdout = '';
|
|
129
|
+
let stderr = '';
|
|
130
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
131
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
132
|
+
child.on('error', reject);
|
|
133
|
+
child.on('close', (code) => code === 0 ? resolve(stdout) : reject(new Error(`STT command exited ${code}: ${stderr}`)));
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function sanitizeProviderError(text) {
|
|
138
|
+
return String(text || '')
|
|
139
|
+
.replace(/sk-[A-Za-z0-9_-]{8,}/g, 'sk-***')
|
|
140
|
+
.replace(/wtt-tok-[A-Za-z0-9_-]{8,}/g, 'wtt-tok-***')
|
|
141
|
+
.trim();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function shellQuote(s) {
|
|
145
|
+
return `'${String(s).replaceAll("'", "'\\''")}'`;
|
|
146
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import * as pty from 'node-pty';
|
|
6
|
+
import { log } from './logger.js';
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
|
|
10
|
+
export class TerminalSessionManager {
|
|
11
|
+
constructor(config, sendJson) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
this.sendJson = sendJson;
|
|
14
|
+
this.sessions = new Map();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async open(msg) {
|
|
18
|
+
if (!this.config.enableShell) {
|
|
19
|
+
await this.sendError(msg.session_id, 'wtt-connect shell is disabled');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const sessionId = String(msg.session_id || '');
|
|
24
|
+
if (!sessionId) return;
|
|
25
|
+
if (this.sessions.has(sessionId)) this.close(sessionId);
|
|
26
|
+
|
|
27
|
+
const baseDir = resolveDir(this.config.workDir);
|
|
28
|
+
const cwd = resolveCwd(baseDir, msg.cwd);
|
|
29
|
+
const shell = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : '/bin/sh');
|
|
30
|
+
const cols = clampInt(msg.cols, 40, 240, 100);
|
|
31
|
+
const rows = clampInt(msg.rows, 10, 80, 28);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
ensureNodePtyHelperExecutable();
|
|
35
|
+
const proc = pty.spawn(shell, [], {
|
|
36
|
+
name: 'xterm-256color',
|
|
37
|
+
cols,
|
|
38
|
+
rows,
|
|
39
|
+
cwd,
|
|
40
|
+
env: {
|
|
41
|
+
...process.env,
|
|
42
|
+
TERM: 'xterm-256color',
|
|
43
|
+
WTT_TERMINAL_SESSION_ID: sessionId,
|
|
44
|
+
WTT_CONNECT_AGENT_ID: this.config.agentId,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const session = { id: sessionId, proc, cwd, closed: false };
|
|
49
|
+
this.sessions.set(sessionId, session);
|
|
50
|
+
|
|
51
|
+
proc.onData((data) => {
|
|
52
|
+
this.sendJson({ type: 'terminal_output', session_id: sessionId, data }).catch((err) => {
|
|
53
|
+
log('warn', 'terminal output relay failed', { sessionId, error: err.message });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
proc.onExit(({ exitCode, signal }) => {
|
|
58
|
+
this.sessions.delete(sessionId);
|
|
59
|
+
this.sendJson({
|
|
60
|
+
type: 'terminal_exit',
|
|
61
|
+
session_id: sessionId,
|
|
62
|
+
exit_code: exitCode,
|
|
63
|
+
signal: signal || '',
|
|
64
|
+
}).catch(() => {});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await this.sendJson({
|
|
68
|
+
type: 'terminal_output',
|
|
69
|
+
session_id: sessionId,
|
|
70
|
+
data: `\r\n[WTT terminal attached: ${cwd}]\r\n`,
|
|
71
|
+
});
|
|
72
|
+
log('info', 'terminal opened', { sessionId, cwd, shell });
|
|
73
|
+
} catch (err) {
|
|
74
|
+
await this.sendError(sessionId, err.message || String(err));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
input(msg) {
|
|
79
|
+
const session = this.sessions.get(String(msg.session_id || ''));
|
|
80
|
+
if (!session) return;
|
|
81
|
+
session.proc.write(String(msg.data || ''));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
resize(msg) {
|
|
85
|
+
const session = this.sessions.get(String(msg.session_id || ''));
|
|
86
|
+
if (!session) return;
|
|
87
|
+
const cols = clampInt(msg.cols, 40, 240, 100);
|
|
88
|
+
const rows = clampInt(msg.rows, 10, 80, 28);
|
|
89
|
+
try {
|
|
90
|
+
session.proc.resize(cols, rows);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
log('warn', 'terminal resize failed', { sessionId: session.id, error: err.message });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
close(sessionId) {
|
|
97
|
+
const sid = String(sessionId || '');
|
|
98
|
+
const session = this.sessions.get(sid);
|
|
99
|
+
if (!session) return;
|
|
100
|
+
this.sessions.delete(sid);
|
|
101
|
+
try {
|
|
102
|
+
session.proc.kill();
|
|
103
|
+
} catch {
|
|
104
|
+
// process may already be gone
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
closeAll() {
|
|
109
|
+
for (const sid of Array.from(this.sessions.keys())) this.close(sid);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async sendError(sessionId, error) {
|
|
113
|
+
if (!sessionId) return;
|
|
114
|
+
await this.sendJson({
|
|
115
|
+
type: 'terminal_error',
|
|
116
|
+
session_id: sessionId,
|
|
117
|
+
error: String(error || 'terminal error'),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function resolveDir(dir) {
|
|
123
|
+
const resolved = path.resolve(String(dir || process.cwd()));
|
|
124
|
+
if (!fs.existsSync(resolved)) fs.mkdirSync(resolved, { recursive: true });
|
|
125
|
+
return fs.realpathSync(resolved);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function resolveCwd(baseDir, requested) {
|
|
129
|
+
if (!requested) return baseDir;
|
|
130
|
+
const raw = String(requested);
|
|
131
|
+
const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(baseDir, raw);
|
|
132
|
+
const real = fs.existsSync(resolved) ? fs.realpathSync(resolved) : resolved;
|
|
133
|
+
return fs.existsSync(real) ? real : baseDir;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function clampInt(value, min, max, fallback) {
|
|
137
|
+
const n = Number.parseInt(String(value || ''), 10);
|
|
138
|
+
if (!Number.isFinite(n)) return fallback;
|
|
139
|
+
return Math.max(min, Math.min(max, n));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function ensureNodePtyHelperExecutable() {
|
|
143
|
+
if (os.platform() !== 'darwin') return;
|
|
144
|
+
try {
|
|
145
|
+
const pkg = require.resolve('node-pty/package.json');
|
|
146
|
+
const arch = os.arch() === 'arm64' ? 'arm64' : 'x64';
|
|
147
|
+
const helper = path.join(path.dirname(pkg), 'prebuilds', `darwin-${arch}`, 'spawn-helper');
|
|
148
|
+
if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755);
|
|
149
|
+
} catch {
|
|
150
|
+
// node-pty may be source-built or installed in a different layout.
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/tts.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
export class TTSManager {
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async synthesize(text, name = 'speech') {
|
|
11
|
+
const provider = this.config.tts.provider;
|
|
12
|
+
if (!provider || provider === 'none') return null;
|
|
13
|
+
await fs.mkdir(this.config.tts.outDir, { recursive: true });
|
|
14
|
+
if (provider === 'macos-say') return this.macosSay(text, name);
|
|
15
|
+
throw new Error(`unsupported TTS provider: ${provider}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async macosSay(text, name) {
|
|
19
|
+
const out = path.join(this.config.tts.outDir, `${safeName(name)}-${Date.now()}.wav`);
|
|
20
|
+
const args = [];
|
|
21
|
+
if (this.config.tts.voice) args.push('-v', this.config.tts.voice);
|
|
22
|
+
args.push('-o', out, '--file-format=WAVE', text);
|
|
23
|
+
await run('say', args);
|
|
24
|
+
return { path: out, mimeType: 'audio/wav' };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function safeName(s) {
|
|
29
|
+
return String(s || 'speech').replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 80);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function run(bin, args) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const child = spawn(bin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
35
|
+
let stderr = '';
|
|
36
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
37
|
+
child.on('error', reject);
|
|
38
|
+
child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`${bin} exited ${code}: ${stderr}`)));
|
|
39
|
+
});
|
|
40
|
+
}
|