@danieltmn/tamnora 0.8.0 → 0.9.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/CHANGELOG.md +83 -0
- package/harness/packages/cli/src/cli.js +1 -1
- package/harness/packages/cli/src/commands/bridge.js +180 -119
- package/harness/packages/cli/src/commands/server.js +26 -50
- package/harness/packages/cli/src/lib/daemon.js +92 -40
- package/harness/packages/cli/test/release.test.js +9 -0
- package/harness/packages/core/src/agent/modes.js +1 -1
- package/harness/packages/core/src/index.js +14 -1
- package/harness/packages/core/src/open.js +43 -0
- package/harness/packages/core/src/pid.js +35 -0
- package/harness/packages/core/src/tools/executors.js +16 -0
- package/harness/packages/core/src/tools/labels.js +2 -0
- package/harness/packages/core/src/tools/permissions.js +1 -0
- package/harness/packages/core/src/tools/schema.js +17 -0
- package/harness/packages/core/src/tts/edge.js +232 -0
- package/harness/packages/core/src/tunnel.js +180 -19
- package/harness/packages/core/test/open.test.js +19 -0
- package/harness/packages/core/test/tools.test.js +12 -0
- package/harness/packages/core/test/tts.test.js +41 -0
- package/harness/packages/server/public/app.js +4715 -3975
- package/harness/packages/server/public/index.html +63 -5
- package/harness/packages/server/public/reconcile.js +121 -0
- package/harness/packages/server/public/relayClient.js +170 -0
- package/harness/packages/server/public/runStream.js +207 -0
- package/harness/packages/server/public/styles.css +142 -5
- package/harness/packages/server/src/auth.js +29 -5
- package/harness/packages/server/src/lib/agentRun.js +77 -0
- package/harness/packages/server/src/lib/announcer.js +92 -0
- package/harness/packages/server/src/lib/approvals.js +25 -3
- package/harness/packages/server/src/lib/channels/telegram.js +85 -20
- package/harness/packages/server/src/lib/ffmpeg.js +48 -0
- package/harness/packages/server/src/lib/procs.js +3 -18
- package/harness/packages/server/src/lib/runManager.js +24 -2
- package/harness/packages/server/src/lib/sessionView.js +43 -0
- package/harness/packages/server/src/lib/tasks.js +4 -9
- package/harness/packages/server/src/lib/ttsSettings.js +42 -0
- package/harness/packages/server/src/lib/workspace.js +37 -1
- package/harness/packages/server/src/routes/chat.js +48 -47
- package/harness/packages/server/src/routes/events.js +35 -0
- package/harness/packages/server/src/routes/files.js +22 -26
- package/harness/packages/server/src/routes/folders.js +9 -17
- package/harness/packages/server/src/routes/git.js +6 -14
- package/harness/packages/server/src/routes/procs.js +14 -16
- package/harness/packages/server/src/routes/sessions.js +12 -19
- package/harness/packages/server/src/routes/shell.js +5 -9
- package/harness/packages/server/src/routes/state.js +32 -0
- package/harness/packages/server/src/routes/tts.js +82 -0
- package/harness/packages/server/src/server.js +21 -1
- package/harness/packages/server/test/announcer.test.js +51 -0
- package/harness/packages/server/test/reconcile.test.js +146 -0
- package/harness/packages/server/test/relay.test.js +219 -0
- package/harness/packages/server/test/runStream.test.js +245 -0
- package/harness/packages/server/test/security.test.js +252 -0
- package/harness/packages/server/test/server.test.js +511 -1
- package/harness/packages/server/test/telegram-voz.test.js +156 -0
- package/harness/packages/server/test/tts.test.js +133 -0
- package/package.json +1 -1
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { mkdtemp, writeFile, unlink, mkdir } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { createTamnoraServer } from '../src/index.js';
|
|
8
|
+
import { setHome, ensureApp, writeApp, makeUser, bridgeStatePath } from '../../core/src/index.js';
|
|
9
|
+
import { isLoopback, localOk } from '../src/lib/workspace.js';
|
|
10
|
+
import { loginClear } from '../src/auth.js';
|
|
11
|
+
|
|
12
|
+
const fakeProvider = {
|
|
13
|
+
id: 'fake',
|
|
14
|
+
async streamChat(_config, { onEvent }) {
|
|
15
|
+
onEvent?.({ type: 'text', delta: 'hola', full: 'hola' });
|
|
16
|
+
return { content: 'hola', reasoning: '', toolCalls: [] };
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
async function startServer(overrides = {}) {
|
|
21
|
+
const cwd = overrides.cwd ?? (await mkdtemp(path.join(tmpdir(), 'tamnora-sec-')));
|
|
22
|
+
setHome(cwd);
|
|
23
|
+
const instance = await createTamnoraServer({
|
|
24
|
+
cwd,
|
|
25
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
26
|
+
loadMcp: false,
|
|
27
|
+
resolveProvider: () => ({ provider: fakeProvider, config: {} }),
|
|
28
|
+
...overrides,
|
|
29
|
+
});
|
|
30
|
+
const info = await instance.listen({ port: 0 });
|
|
31
|
+
return { instance, base: info.url, cwd, home: cwd };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Deja un bridge.json apuntando a un supervisor vivo (este proceso). */
|
|
35
|
+
async function fakeTunnel() {
|
|
36
|
+
await writeFile(bridgeStatePath(), `${JSON.stringify({ pid: process.pid, url: 'https://fake.tunnelmole.net', provider: 'tunnelmole', port: 4517 })}\n`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function clearTunnel() {
|
|
40
|
+
try {
|
|
41
|
+
await unlink(bridgeStatePath());
|
|
42
|
+
} catch {
|
|
43
|
+
/* nada */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test('isLoopback solo acepta direcciones de loopback', () => {
|
|
48
|
+
for (const good of ['127.0.0.1', '127.9.9.9', '::1', '0:0:0:0:0:0:0:1', '::ffff:127.0.0.1']) {
|
|
49
|
+
assert.ok(isLoopback(good), good);
|
|
50
|
+
}
|
|
51
|
+
for (const bad of ['192.168.1.5', '::ffff:192.168.1.5', '', null, 'fe80::1']) {
|
|
52
|
+
assert.ok(!isLoopback(bad), String(bad));
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('localOk exige loopback en remoteAddress y sin tunel activo', async () => {
|
|
57
|
+
const fake = (remote) => ({ socket: { remoteAddress: remote } });
|
|
58
|
+
assert.ok(localOk(fake('127.0.0.1')));
|
|
59
|
+
assert.ok(!localOk(fake('192.168.1.5')));
|
|
60
|
+
// localAddress (la direccion de bind) ya no engana.
|
|
61
|
+
assert.ok(!localOk({ socket: { remoteAddress: '10.0.0.8', localAddress: '127.0.0.1' } }));
|
|
62
|
+
|
|
63
|
+
const { instance, home } = await startServer();
|
|
64
|
+
try {
|
|
65
|
+
// Con tunel activo, hasta una conexion loopback deja de ser local.
|
|
66
|
+
await fakeTunnel();
|
|
67
|
+
// El cache de tunnelState vive 1 s.
|
|
68
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
69
|
+
assert.ok(!localOk({ socket: { remoteAddress: '127.0.0.1' } }));
|
|
70
|
+
assert.equal(home, path.dirname(bridgeStatePath()));
|
|
71
|
+
} finally {
|
|
72
|
+
await clearTunnel();
|
|
73
|
+
await instance.close();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('POST /api/shell responde 403 mientras el tunel esta activo', async () => {
|
|
78
|
+
const { instance, base } = await startServer();
|
|
79
|
+
try {
|
|
80
|
+
const shell = (extraHeaders = {}) =>
|
|
81
|
+
fetch(`${base}/api/shell`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'Content-Type': 'application/json', ...extraHeaders },
|
|
84
|
+
body: JSON.stringify({ command: 'echo tamnora-ok' }),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const ok = await shell();
|
|
88
|
+
assert.equal(ok.status, 200);
|
|
89
|
+
const body = await ok.json();
|
|
90
|
+
assert.match(String(body.output), /tamnora-ok/);
|
|
91
|
+
|
|
92
|
+
await fakeTunnel();
|
|
93
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
94
|
+
const blocked = await shell();
|
|
95
|
+
assert.equal(blocked.status, 403);
|
|
96
|
+
|
|
97
|
+
// Con header falsificado tampoco pasa: remoteAddress manda.
|
|
98
|
+
const spoofed = await shell({ 'x-forwarded-for': '203.0.113.9' });
|
|
99
|
+
assert.equal(spoofed.status, 403);
|
|
100
|
+
} finally {
|
|
101
|
+
await clearTunnel();
|
|
102
|
+
await instance.close();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('GET /api/files: sin tunel acepta ?root; con tunel y sin auth responde 403', async () => {
|
|
107
|
+
const { instance, base } = await startServer();
|
|
108
|
+
try {
|
|
109
|
+
// Sin tunel y desde loopback el request es local: cualquier raiz va.
|
|
110
|
+
const outside = await mkdtemp(path.join(tmpdir(), 'tamnora-fuera-'));
|
|
111
|
+
mkdir(path.join(outside, 'sub'));
|
|
112
|
+
const free = await (await fetch(`${base}/api/files?root=${encodeURIComponent(outside)}`)).json();
|
|
113
|
+
assert.equal(free.root, outside, 'conexion local puede explorar otra raiz');
|
|
114
|
+
|
|
115
|
+
const escape = await fetch(`${base}/api/files?path=..%2F..&root=${encodeURIComponent(outside)}`);
|
|
116
|
+
assert.equal(escape.status, 400);
|
|
117
|
+
|
|
118
|
+
await fakeTunnel();
|
|
119
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
120
|
+
const viaTunnel = await fetch(`${base}/api/files`);
|
|
121
|
+
assert.equal(viaTunnel.status, 403);
|
|
122
|
+
} finally {
|
|
123
|
+
await clearTunnel();
|
|
124
|
+
await instance.close();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('con auth, git/files quedan usables por el tunel y confinados al workspace', async () => {
|
|
129
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-sec-auth-'));
|
|
130
|
+
setHome(cwd);
|
|
131
|
+
const app = ensureApp();
|
|
132
|
+
app.user = makeUser('admin', 'secreto123');
|
|
133
|
+
writeApp(app);
|
|
134
|
+
|
|
135
|
+
const instance = await createTamnoraServer({
|
|
136
|
+
cwd,
|
|
137
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
138
|
+
loadMcp: false,
|
|
139
|
+
resolveProvider: () => ({ provider: fakeProvider, config: {} }),
|
|
140
|
+
});
|
|
141
|
+
const info = await instance.listen({ port: 0 });
|
|
142
|
+
const base = info.url;
|
|
143
|
+
try {
|
|
144
|
+
const login = await fetch(`${base}/api/auth/login`, {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: { 'Content-Type': 'application/json' },
|
|
147
|
+
body: JSON.stringify({ username: 'admin', password: 'secreto123' }),
|
|
148
|
+
});
|
|
149
|
+
assert.equal(login.status, 200);
|
|
150
|
+
const cookie = login.headers.get('set-cookie');
|
|
151
|
+
const { csrf } = await login.json();
|
|
152
|
+
|
|
153
|
+
await fakeTunnel();
|
|
154
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
155
|
+
|
|
156
|
+
const files = await fetch(`${base}/api/files`, { headers: { cookie } });
|
|
157
|
+
assert.equal(files.status, 200);
|
|
158
|
+
const listing = await files.json();
|
|
159
|
+
assert.equal(listing.root, cwd, 'por el tunel la raiz es el workspace');
|
|
160
|
+
|
|
161
|
+
const shell = await fetch(`${base}/api/shell`, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'Content-Type': 'application/json', cookie, 'x-csrf': csrf },
|
|
164
|
+
body: JSON.stringify({ command: 'echo nope' }),
|
|
165
|
+
});
|
|
166
|
+
assert.equal(shell.status, 403, 'el shell directo sigue cerrado por el tunel');
|
|
167
|
+
} finally {
|
|
168
|
+
await clearTunnel();
|
|
169
|
+
await instance.close();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('el lockout de login no se esquiva falsificando X-Forwarded-For', async () => {
|
|
174
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-sec-lock-'));
|
|
175
|
+
setHome(cwd);
|
|
176
|
+
const app = ensureApp();
|
|
177
|
+
app.user = makeUser('locktest', 'secreto123');
|
|
178
|
+
writeApp(app);
|
|
179
|
+
|
|
180
|
+
const instance = await createTamnoraServer({
|
|
181
|
+
cwd,
|
|
182
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
183
|
+
loadMcp: false,
|
|
184
|
+
resolveProvider: () => ({ provider: fakeProvider, config: {} }),
|
|
185
|
+
});
|
|
186
|
+
const info = await instance.listen({ port: 0 });
|
|
187
|
+
const base = info.url;
|
|
188
|
+
try {
|
|
189
|
+
const attempt = (xff) =>
|
|
190
|
+
fetch(`${base}/api/auth/login`, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
headers: { 'Content-Type': 'application/json', ...(xff ? { 'x-forwarded-for': xff } : {}) },
|
|
193
|
+
body: JSON.stringify({ username: 'locktest', password: 'mala' }),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
for (let i = 0; i < 5; i++) {
|
|
197
|
+
await attempt(i % 2 ? '203.0.113.1' : '203.0.113.2');
|
|
198
|
+
}
|
|
199
|
+
const lockedPlain = await attempt();
|
|
200
|
+
assert.equal(lockedPlain.status, 429);
|
|
201
|
+
|
|
202
|
+
const lockedSpoof = await attempt('198.51.100.7');
|
|
203
|
+
assert.equal(lockedSpoof.status, 429, 'XFF falsificado no resetea el contador');
|
|
204
|
+
} finally {
|
|
205
|
+
loginClear({ socket: { remoteAddress: '127.0.0.1' } }, 'locktest');
|
|
206
|
+
await instance.close();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('la cookie va con Secure cuando hay tunel y el Host es publico', async () => {
|
|
211
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-sec-cookie-'));
|
|
212
|
+
setHome(cwd);
|
|
213
|
+
const app = ensureApp();
|
|
214
|
+
app.user = makeUser('admin', 'secreto123');
|
|
215
|
+
writeApp(app);
|
|
216
|
+
|
|
217
|
+
const instance = await createTamnoraServer({
|
|
218
|
+
cwd,
|
|
219
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
220
|
+
loadMcp: false,
|
|
221
|
+
resolveProvider: () => ({ provider: fakeProvider, config: {} }),
|
|
222
|
+
});
|
|
223
|
+
const info = await instance.listen({ port: 0 });
|
|
224
|
+
try {
|
|
225
|
+
await fakeTunnel();
|
|
226
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
227
|
+
|
|
228
|
+
const rawLogin = (host) =>
|
|
229
|
+
new Promise((resolve, reject) => {
|
|
230
|
+
const body = JSON.stringify({ username: 'admin', password: 'secreto123' });
|
|
231
|
+
const req = http.request(
|
|
232
|
+
{ host: '127.0.0.1', port: info.port, path: '/api/auth/login', method: 'POST', headers: { Host: host, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } },
|
|
233
|
+
(res) => {
|
|
234
|
+
let out = '';
|
|
235
|
+
res.on('data', (c) => (out += c));
|
|
236
|
+
res.on('end', () => resolve({ status: res.statusCode, setCookie: res.headers['set-cookie']?.[0] ?? '' }));
|
|
237
|
+
}
|
|
238
|
+
);
|
|
239
|
+
req.on('error', reject);
|
|
240
|
+
req.end(body);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const viaTunnel = await rawLogin('algo.tunnelmole.net');
|
|
244
|
+
assert.match(String(viaTunnel.setCookie), /Secure/, 'por el tunel la cookie es Secure');
|
|
245
|
+
|
|
246
|
+
const local = await rawLogin('localhost:4517');
|
|
247
|
+
assert.ok(!/Secure/i.test(String(local.setCookie)), 'local http no fuerza Secure');
|
|
248
|
+
} finally {
|
|
249
|
+
await clearTunnel();
|
|
250
|
+
await instance.close();
|
|
251
|
+
}
|
|
252
|
+
});
|