@danieltmn/tamnora 0.7.1 → 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.
Files changed (66) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +9 -0
  3. package/SECURITY.md +11 -6
  4. package/harness/packages/cli/src/cli.js +1 -1
  5. package/harness/packages/cli/src/commands/bridge.js +180 -119
  6. package/harness/packages/cli/src/commands/run.js +1 -1
  7. package/harness/packages/cli/src/commands/server.js +26 -50
  8. package/harness/packages/cli/src/commands/tui.js +1 -1
  9. package/harness/packages/cli/src/lib/daemon.js +92 -40
  10. package/harness/packages/cli/test/release.test.js +9 -0
  11. package/harness/packages/core/src/agent/modes.js +1 -1
  12. package/harness/packages/core/src/agent/runAgent.js +3 -0
  13. package/harness/packages/core/src/git/index.js +177 -0
  14. package/harness/packages/core/src/index.js +16 -1
  15. package/harness/packages/core/src/open.js +43 -0
  16. package/harness/packages/core/src/pid.js +35 -0
  17. package/harness/packages/core/src/procs/kill.js +95 -0
  18. package/harness/packages/core/src/tools/executors.js +16 -0
  19. package/harness/packages/core/src/tools/labels.js +2 -0
  20. package/harness/packages/core/src/tools/permissions.js +1 -0
  21. package/harness/packages/core/src/tools/schema.js +17 -0
  22. package/harness/packages/core/src/tts/edge.js +232 -0
  23. package/harness/packages/core/src/tunnel.js +180 -19
  24. package/harness/packages/core/test/open.test.js +19 -0
  25. package/harness/packages/core/test/procs.test.js +50 -0
  26. package/harness/packages/core/test/tools.test.js +12 -0
  27. package/harness/packages/core/test/tts.test.js +41 -0
  28. package/harness/packages/server/public/app.js +4715 -3669
  29. package/harness/packages/server/public/index.html +94 -5
  30. package/harness/packages/server/public/landing/index.html +5 -5
  31. package/harness/packages/server/public/reconcile.js +121 -0
  32. package/harness/packages/server/public/relayClient.js +170 -0
  33. package/harness/packages/server/public/runStream.js +207 -0
  34. package/harness/packages/server/public/styles.css +196 -23
  35. package/harness/packages/server/src/auth.js +29 -5
  36. package/harness/packages/server/src/lib/agentRun.js +77 -0
  37. package/harness/packages/server/src/lib/announcer.js +92 -0
  38. package/harness/packages/server/src/lib/approvals.js +25 -3
  39. package/harness/packages/server/src/lib/channels/telegram.js +85 -20
  40. package/harness/packages/server/src/lib/ffmpeg.js +48 -0
  41. package/harness/packages/server/src/lib/procs.js +3 -18
  42. package/harness/packages/server/src/lib/runManager.js +24 -2
  43. package/harness/packages/server/src/lib/sessionView.js +43 -0
  44. package/harness/packages/server/src/lib/tasks.js +4 -9
  45. package/harness/packages/server/src/lib/ttsSettings.js +42 -0
  46. package/harness/packages/server/src/lib/workspace.js +37 -1
  47. package/harness/packages/server/src/routes/chat.js +51 -47
  48. package/harness/packages/server/src/routes/events.js +35 -0
  49. package/harness/packages/server/src/routes/files.js +22 -26
  50. package/harness/packages/server/src/routes/folders.js +9 -17
  51. package/harness/packages/server/src/routes/git.js +35 -0
  52. package/harness/packages/server/src/routes/procs.js +25 -15
  53. package/harness/packages/server/src/routes/sessions.js +12 -19
  54. package/harness/packages/server/src/routes/shell.js +19 -0
  55. package/harness/packages/server/src/routes/state.js +32 -0
  56. package/harness/packages/server/src/routes/tts.js +82 -0
  57. package/harness/packages/server/src/server.js +25 -1
  58. package/harness/packages/server/test/announcer.test.js +51 -0
  59. package/harness/packages/server/test/reconcile.test.js +146 -0
  60. package/harness/packages/server/test/relay.test.js +219 -0
  61. package/harness/packages/server/test/runStream.test.js +245 -0
  62. package/harness/packages/server/test/security.test.js +252 -0
  63. package/harness/packages/server/test/server.test.js +617 -1
  64. package/harness/packages/server/test/telegram-voz.test.js +156 -0
  65. package/harness/packages/server/test/tts.test.js +133 -0
  66. package/package.json +1 -1
@@ -0,0 +1,245 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { streamSse, followRun, backoffDelay, linkSignals, delay } from '../public/runStream.js';
4
+
5
+ /** Arma una `fetch` falsa que devuelve los chunks indicados y despues se cuelga. */
6
+ function stubFetch(chunks, { status = 200, hang = true } = {}) {
7
+ const queue = [...chunks];
8
+ const enqueue = (controller) => {
9
+ // Sin chunks pendientes el stream queda abierto y mudo: eso es justo lo que
10
+ // el watchdog tiene que detectar, asi que no cerramos nada. Con `hang:false`
11
+ // se cierra. `queueMicrotask` y no `setTimeout`: no deja timers colgados
12
+ // entre tests.
13
+ if (!queue.length) {
14
+ if (!hang) controller.close();
15
+ return;
16
+ }
17
+ controller.enqueue(new TextEncoder().encode(queue.shift()));
18
+ queueMicrotask(() => enqueue(controller));
19
+ };
20
+ return async (_url, options = {}) => {
21
+ if (status !== 200) {
22
+ return {
23
+ ok: false,
24
+ status,
25
+ statusText: 'Error',
26
+ json: async () => ({ error: 'no autorizado' }),
27
+ };
28
+ }
29
+ // Un fetch real aborta el body cuando se dispara la senal: hay que
30
+ // reproducirlo o el watchdog no puede cortar el `reader.read()`.
31
+ const body = new ReadableStream({
32
+ start(controller) {
33
+ const stop = () => controller.error(new DOMException('Aborted', 'AbortError'));
34
+ if (options.signal?.aborted) return stop();
35
+ options.signal?.addEventListener('abort', stop, { once: true });
36
+ enqueue(controller);
37
+ },
38
+ });
39
+ return { ok: true, status: 200, body };
40
+ };
41
+ }
42
+
43
+ test('entrega los frames parseados y recuerda el ultimo id', async () => {
44
+ const frames = [];
45
+ await streamSse('/x', { fetchImpl: stubFetch(['event: start\ndata: {"a":1}\nid: 7\n\n: ping\n\nevent: done\ndata: {}\nid: 9\n\n'], { hang: false }) }, (event, data, id) => {
46
+ frames.push([event, data, id]);
47
+ });
48
+ assert.deepEqual(frames, [
49
+ ['start', { a: 1 }, '7'],
50
+ ['done', {}, '9'],
51
+ ]);
52
+ });
53
+
54
+ test('usa el fetch global cuando no se pasa fetchImpl', async () => {
55
+ // La consola nunca pasa fetchImpl. Si el default se rompe, todos los streams
56
+ // fallan con TypeError y followRun los reintenta para siempre.
57
+ const original = globalThis.fetch;
58
+ let called = false;
59
+ globalThis.fetch = async (url) => {
60
+ called = true;
61
+ assert.equal(url, '/api/events');
62
+ return { ok: true, status: 200, body: new ReadableStream({ start: (c) => c.close() }) };
63
+ };
64
+ try {
65
+ const frames = [];
66
+ await streamSse('/api/events', {}, (event, data) => frames.push([event, data]));
67
+ assert.equal(called, true);
68
+ assert.deepEqual(frames, []);
69
+ } finally {
70
+ globalThis.fetch = original;
71
+ }
72
+ });
73
+
74
+ test('tolera frames partidas entre varios chunks', async () => {
75
+ const frames = [];
76
+ await streamSse('/x', { fetchImpl: stubFetch(['event: te', 'xt\ndata: {"b', '":2}\nid: 3\n\n'], { hang: false }) }, (event, data, id) => {
77
+ frames.push([event, data, id]);
78
+ });
79
+ assert.deepEqual(frames, [['text', { b: 2 }, '3']]);
80
+ });
81
+
82
+ test('los comentarios de heartbeat no producen eventos', async () => {
83
+ const frames = [];
84
+ await streamSse('/x', { fetchImpl: stubFetch([': ping\n\n: ping\n\nevent: done\ndata: {}\n\n'], { hang: false }) }, (event) => {
85
+ frames.push(event);
86
+ });
87
+ assert.deepEqual(frames, ['done']);
88
+ });
89
+
90
+ test('corta cuando el stream queda mudo y reporta el cursor', async () => {
91
+ await assert.rejects(
92
+ streamSse('/x', { silenceMs: 40, fetchImpl: stubFetch(['event: start\ndata: {}\nid: 12\n\n']) }, () => {}),
93
+ (error) => {
94
+ assert.equal(error.name, 'SilentStream');
95
+ assert.equal(error.resumable, true);
96
+ assert.equal(error.from, '12');
97
+ return true;
98
+ }
99
+ );
100
+ });
101
+
102
+ test('propaga el status HTTP para distinguir un 409', async () => {
103
+ await assert.rejects(
104
+ streamSse('/x', { fetchImpl: stubFetch([], { status: 409 }) }, () => {}),
105
+ (error) => {
106
+ assert.equal(error.status, 409);
107
+ assert.equal(error.message, 'no autorizado');
108
+ return true;
109
+ }
110
+ );
111
+ });
112
+
113
+ test('el AbortError del caller no se reporta como stream mudo', async () => {
114
+ const controller = new AbortController();
115
+ controller.abort();
116
+ await assert.rejects(
117
+ streamSse('/x', { signal: controller.signal, fetchImpl: stubFetch([]) }, () => {}),
118
+ (error) => error.name === 'AbortError'
119
+ );
120
+ });
121
+
122
+ test('followRun reintenta con backoff creciente hasta terminar', async () => {
123
+ const slept = [];
124
+ let calls = 0;
125
+ const result = await followRun({
126
+ open: async (cursor) => {
127
+ calls += 1;
128
+ if (calls < 4) {
129
+ const error = new Error('se corto');
130
+ error.name = 'SilentStream';
131
+ error.from = String(cursor + 10);
132
+ throw error;
133
+ }
134
+ return { finished: true, cursor: 99 };
135
+ },
136
+ sleep: async (ms) => {
137
+ slept.push(ms);
138
+ },
139
+ });
140
+ assert.equal(calls, 4);
141
+ assert.deepEqual(slept, [1000, 2000, 4000]);
142
+ assert.equal(result.finished, true);
143
+ assert.equal(result.cursor, 99);
144
+ });
145
+
146
+ test('followRun no reintenta un AbortError', async () => {
147
+ let calls = 0;
148
+ await assert.rejects(
149
+ followRun({
150
+ open: async () => {
151
+ calls += 1;
152
+ throw new DOMException('Aborted', 'AbortError');
153
+ },
154
+ sleep: async () => {},
155
+ }),
156
+ (error) => error.name === 'AbortError'
157
+ );
158
+ assert.equal(calls, 1);
159
+ });
160
+
161
+ test('followRun respeta el limite de intentos', async () => {
162
+ let calls = 0;
163
+ await assert.rejects(
164
+ followRun({
165
+ attempts: 2,
166
+ open: async () => {
167
+ calls += 1;
168
+ throw new Error('siempre falla');
169
+ },
170
+ sleep: async () => {},
171
+ }),
172
+ /siempre falla/
173
+ );
174
+ assert.equal(calls, 3);
175
+ });
176
+
177
+ test('followRun avisa el cursor que va a reanudar', async () => {
178
+ const retries = [];
179
+ await followRun({
180
+ open: async (cursor) => {
181
+ if (cursor === 0) {
182
+ const error = new Error('murio');
183
+ error.name = 'SilentStream';
184
+ error.from = '55';
185
+ throw error;
186
+ }
187
+ return { finished: true };
188
+ },
189
+ onRetry: (info) => retries.push([info.cursor, info.attempt]),
190
+ sleep: async () => {},
191
+ });
192
+ assert.deepEqual(retries, [[55, 1]]);
193
+ });
194
+
195
+ test('un error del handler no se reintenta en bucle', async () => {
196
+ // Si se confundiera con una caida del transporte, el sintoma seria un
197
+ // "reconectando..." eterno que no dice nada del bug real.
198
+ let calls = 0;
199
+ const boom = await followRun({
200
+ attempts: Infinity,
201
+ open: async () => {
202
+ calls += 1;
203
+ const error = new Error('handler roto');
204
+ error.eventHandler = true;
205
+ throw error;
206
+ },
207
+ sleep: async () => {},
208
+ }).catch((error) => error);
209
+ assert.equal(calls, 1, 'no debe reintentar');
210
+ assert.ok(boom.eventHandler, 'debe marcar el error como del handler');
211
+ });
212
+
213
+ test('un error del handler se propaga marcado', async () => {
214
+ await assert.rejects(
215
+ streamSse('/x', { fetchImpl: stubFetch(['event: text\ndata: {}\n\n'], { hang: false }) }, () => {
216
+ throw new Error('se rompio el render');
217
+ }),
218
+ (error) => {
219
+ assert.equal(error.message, 'se rompio el render');
220
+ assert.equal(error.eventHandler, true);
221
+ return true;
222
+ }
223
+ );
224
+ });
225
+
226
+ test('backoffDelay acota en el ultimo paso', () => {
227
+ assert.equal(backoffDelay(0), 1000);
228
+ assert.equal(backoffDelay(3), 8000);
229
+ assert.equal(backoffDelay(99), 10000);
230
+ });
231
+
232
+ test('linkSignals aborta si cualquiera de las senales dispara', () => {
233
+ const a = new AbortController();
234
+ const b = new AbortController();
235
+ const linked = linkSignals([a.signal, b.signal]);
236
+ assert.equal(linked.signal.aborted, false);
237
+ b.abort();
238
+ assert.equal(linked.signal.aborted, true);
239
+ });
240
+
241
+ test('delay se corta cuando abortan', async () => {
242
+ const controller = new AbortController();
243
+ setTimeout(() => controller.abort(), 5);
244
+ await assert.rejects(delay(5000, controller.signal), (error) => error.name === 'AbortError');
245
+ });
@@ -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
+ });