@danieltmn/tamnora 0.21.2 → 0.22.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 (47) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/harness/packages/core/src/agent/runAgent.js +9 -5
  3. package/harness/packages/core/src/index.js +1 -0
  4. package/harness/packages/core/src/mcp/stdio.js +15 -0
  5. package/harness/packages/core/src/net/hosts.js +70 -0
  6. package/harness/packages/core/src/redact.js +2 -0
  7. package/harness/packages/core/src/tools/executors.js +19 -24
  8. package/harness/packages/core/src/tools/index.js +1 -1
  9. package/harness/packages/core/src/tools/paths.js +35 -5
  10. package/harness/packages/core/src/tools/permissions.js +41 -2
  11. package/harness/packages/core/src/tools/registry.js +8 -3
  12. package/harness/packages/core/src/tunnel.js +15 -5
  13. package/harness/packages/core/test/tools.test.js +44 -1
  14. package/harness/packages/server/public/app.js +343 -6754
  15. package/harness/packages/server/public/chat.js +1893 -0
  16. package/harness/packages/server/public/chrome.js +812 -0
  17. package/harness/packages/server/public/commands.js +403 -0
  18. package/harness/packages/server/public/core.js +209 -0
  19. package/harness/packages/server/public/explorer.js +625 -0
  20. package/harness/packages/server/public/index.html +19 -19
  21. package/harness/packages/server/public/sessions.js +274 -0
  22. package/harness/packages/server/public/settings.js +1076 -0
  23. package/harness/packages/server/public/styles.css +25 -2
  24. package/harness/packages/server/public/system.js +495 -0
  25. package/harness/packages/server/public/voice.js +1058 -0
  26. package/harness/packages/server/src/auth.js +12 -10
  27. package/harness/packages/server/src/lib/agentRun.js +6 -1
  28. package/harness/packages/server/src/lib/channels/telegram.js +1 -0
  29. package/harness/packages/server/src/lib/presence.js +12 -1
  30. package/harness/packages/server/src/lib/scheduler.js +11 -2
  31. package/harness/packages/server/src/lib/tasks.js +20 -3
  32. package/harness/packages/server/src/lib/workspace.js +90 -2
  33. package/harness/packages/server/src/routes/auth.js +8 -4
  34. package/harness/packages/server/src/routes/chat.js +15 -4
  35. package/harness/packages/server/src/routes/preview.js +10 -2
  36. package/harness/packages/server/src/routes/providers.js +12 -9
  37. package/harness/packages/server/src/server.js +19 -0
  38. package/harness/packages/server/test/chat.test.js +586 -0
  39. package/harness/packages/server/test/helpers.js +107 -0
  40. package/harness/packages/server/test/ops.test.js +511 -0
  41. package/harness/packages/server/test/runs.test.js +816 -0
  42. package/harness/packages/server/test/security.test.js +112 -9
  43. package/harness/packages/server/test/sessions.test.js +437 -0
  44. package/harness/packages/server/test/settings.test.js +465 -0
  45. package/harness/tamnora.example.json +1 -1
  46. package/package.json +1 -1
  47. package/harness/packages/server/test/server.test.js +0 -2885
@@ -0,0 +1,511 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import http from 'node:http';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { mkdtemp, mkdir, writeFile, stat } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import path from 'node:path';
8
+ import { createTamnoraServer } from '../src/index.js';
9
+ import { setHome, ensureApp, readApp, writeApp, makeUser, screenshotsDir } from '../../core/src/index.js';
10
+ import { fakeProvider, startServer, HAS_GIT, gitInit } from './helpers.js';
11
+
12
+ test('serves health, providers and the console', async () => {
13
+ const { instance, base } = await startServer();
14
+ try {
15
+ const health = await (await fetch(`${base}/api/health`)).json();
16
+ assert.equal(health.ok, true);
17
+ assert.equal(health.name, 'tamnora');
18
+
19
+ const providers = await (await fetch(`${base}/api/providers`)).json();
20
+ assert.ok(providers.providers.some((p) => p.id === 'openrouter'));
21
+ assert.equal(providers.active.provider, 'openrouter');
22
+
23
+ const html = await (await fetch(`${base}/`)).text();
24
+ assert.match(html, /Tamnora Console/);
25
+ for (const hook of [
26
+ 'data-panel="agente"',
27
+ 'data-panel="sesiones"',
28
+ 'id="settings-controls-slot"',
29
+ 'id="slash-menu"',
30
+ 'id="statusbar"',
31
+ 'id="sb-activity"',
32
+ // Nueva sesion tambien en el topbar: en movil el sidebar es un drawer y
33
+ // crear una sesion eran varios toques.
34
+ 'id="new-session-top"',
35
+ ]) {
36
+ assert.ok(html.includes(hook), `falta ${hook}`);
37
+ }
38
+ // El boton del topbar tiene que estar en el header, no escondido en el
39
+ // drawer (que es donde vive `#new-session`).
40
+ const header = html.slice(0, html.indexOf('<main'));
41
+ assert.ok(header.includes('id="new-session-top"'), 'nueva sesion deberia estar en el topbar');
42
+ assert.equal((html.match(/id="new-session-top"/g) ?? []).length, 1);
43
+ assert.equal((html.match(/id="new-session"/g) ?? []).length, 1);
44
+ // Los controles de agente se declaran una sola vez (se mueven entre slots).
45
+ assert.equal((html.match(/id="mode"/g) ?? []).length, 1);
46
+
47
+ const missing = await fetch(`${base}/api/nope`);
48
+ assert.equal(missing.status, 404);
49
+ } finally {
50
+ await instance.close();
51
+ }
52
+ });
53
+
54
+ test('manages scheduled tasks from the console', async () => {
55
+ const { instance, base } = await startServer();
56
+ const json = { 'Content-Type': 'application/json' };
57
+ const create = (body) => fetch(`${base}/api/schedules`, { method: 'POST', headers: json, body: JSON.stringify(body) });
58
+ try {
59
+ let res = await create({ id: 'nightly', type: 'agent', spec: 'daily 03:00', prompt: 'hacé el resumen' });
60
+ assert.equal(res.status, 201);
61
+ let data = await res.json();
62
+ assert.equal(data.schedule.id, 'nightly');
63
+ assert.equal(data.schedule.enabled, true);
64
+ assert.ok(data.schedule.nextRun);
65
+
66
+ res = await create({ id: 'bad', spec: 'nope', prompt: 'x' });
67
+ assert.equal(res.status, 400);
68
+
69
+ res = await create({ id: 'nightly', type: 'agent', spec: 'daily 03:00', prompt: 'otra' });
70
+ assert.equal(res.status, 409);
71
+
72
+ res = await fetch(`${base}/api/schedules/nightly/toggle`, { method: 'POST' });
73
+ data = await res.json();
74
+ assert.equal(data.enabled, false);
75
+
76
+ data = await (await fetch(`${base}/api/schedules`)).json();
77
+ assert.equal(data.schedules.length, 1);
78
+ assert.equal(data.schedules[0].enabled, false);
79
+
80
+ res = await fetch(`${base}/api/schedules/nightly`, { method: 'DELETE' });
81
+ assert.equal(res.status, 200);
82
+ data = await res.json();
83
+ assert.equal(data.schedules.length, 0);
84
+ } finally {
85
+ await instance.close();
86
+ }
87
+ });
88
+
89
+ test('runs a scheduled task on demand (shell and agent)', async () => {
90
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-sched-'));
91
+ const provider = {
92
+ id: 'fake',
93
+ async streamChat(_config, { onEvent }) {
94
+ onEvent?.({ type: 'text', delta: 'ok', full: 'ok' });
95
+ return { content: 'ok', reasoning: '', toolCalls: [] };
96
+ },
97
+ };
98
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
99
+ const json = { 'Content-Type': 'application/json' };
100
+ const create = (body) => fetch(`${base}/api/schedules`, { method: 'POST', headers: json, body: JSON.stringify(body) });
101
+ let data;
102
+ try {
103
+ await create({ id: 'ver', type: 'shell', spec: 'every 1 h', command: 'node', args: ['--version'] });
104
+ let res = await fetch(`${base}/api/schedules/ver/run`, { method: 'POST' });
105
+ assert.equal(res.status, 200);
106
+ assert.equal((await res.json()).ok, true);
107
+
108
+ await create({ id: 'nope', type: 'shell', spec: 'every 1 h', command: 'definitely-not-allowed' });
109
+ res = await fetch(`${base}/api/schedules/nope/run`, { method: 'POST' });
110
+ assert.equal(res.status, 500);
111
+
112
+ await create({ id: 'job', type: 'agent', spec: 'every 1 h', prompt: 'hacé algo útil', model: 'm' });
113
+ res = await fetch(`${base}/api/schedules/job/run`, { method: 'POST' });
114
+ data = await res.json();
115
+ assert.equal(res.status, 200, JSON.stringify(data));
116
+ assert.equal(data.ok, true, JSON.stringify(data));
117
+ assert.ok(data.result.sessionId);
118
+
119
+ const sessions = await (await fetch(`${base}/api/sessions`)).json();
120
+ assert.ok(sessions.sessions.some((s) => s.title === 'Tarea: job'));
121
+ } finally {
122
+ await instance.close();
123
+ }
124
+ });
125
+
126
+ test('detects, runs and stops dev processes', async () => {
127
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
128
+ await writeFile(path.join(cwd, 'package.json'), JSON.stringify({ scripts: { dev: 'node server.js' } }));
129
+ const { instance, base } = await startServer({ cwd });
130
+ const json = { 'Content-Type': 'application/json' };
131
+ try {
132
+ const detected = await (await fetch(`${base}/api/procs/detect?cwd=${encodeURIComponent(cwd)}`)).json();
133
+ assert.ok(detected.chips.some((c) => c.command === 'npm' && c.args.includes('dev')));
134
+
135
+ const blocked = await fetch(`${base}/api/procs/start`, {
136
+ method: 'POST',
137
+ headers: json,
138
+ body: JSON.stringify({ cwd, command: 'curl' }),
139
+ });
140
+ assert.equal(blocked.status, 400);
141
+
142
+ const started = await (
143
+ await fetch(`${base}/api/procs/start`, {
144
+ method: 'POST',
145
+ headers: json,
146
+ body: JSON.stringify({ cwd, command: 'node', args: ['-e', 'console.log(42)'] }),
147
+ })
148
+ ).json();
149
+ assert.ok(started.proc.id);
150
+ await new Promise((resolve) => setTimeout(resolve, 500));
151
+
152
+ const logs = await (await fetch(`${base}/api/procs/${started.proc.id}/logs`)).json();
153
+ assert.match(logs.logs.map((l) => l.text).join(''), /42/);
154
+
155
+ const stopped = await fetch(`${base}/api/procs/${started.proc.id}/stop`, { method: 'POST' });
156
+ assert.equal(stopped.status, 200);
157
+
158
+ const badTunnel = await fetch(`${base}/api/preview/tunnel`, {
159
+ method: 'POST',
160
+ headers: json,
161
+ body: JSON.stringify({ port: 0 }),
162
+ });
163
+ assert.equal(badTunnel.status, 400);
164
+ } finally {
165
+ await instance.close();
166
+ }
167
+ });
168
+
169
+ test('runs a console shell command and kills a port', async () => {
170
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-shell-'));
171
+ const { instance, base } = await startServer({ cwd });
172
+ const json = { 'Content-Type': 'application/json' };
173
+ try {
174
+ const shell = await fetch(`${base}/api/shell`, {
175
+ method: 'POST',
176
+ headers: json,
177
+ body: JSON.stringify({ command: 'echo hola-shell' }),
178
+ });
179
+ assert.equal(shell.status, 200);
180
+ const out = await shell.json();
181
+ assert.match(out.output, /hola-shell/);
182
+
183
+ const missing = await fetch(`${base}/api/shell`, {
184
+ method: 'POST',
185
+ headers: json,
186
+ body: JSON.stringify({ command: ' ' }),
187
+ });
188
+ assert.equal(missing.status, 400);
189
+
190
+ const badPort = await fetch(`${base}/api/procs/kill`, {
191
+ method: 'POST',
192
+ headers: json,
193
+ body: JSON.stringify({ port: 0 }),
194
+ });
195
+ assert.equal(badPort.status, 400);
196
+
197
+ const none = await fetch(`${base}/api/procs/kill`, {
198
+ method: 'POST',
199
+ headers: json,
200
+ body: JSON.stringify({ port: 47555 }),
201
+ });
202
+ assert.equal(none.status, 200);
203
+ assert.deepEqual((await none.json()).pids, []);
204
+
205
+ const status = await fetch(`${base}/api/ports/47555`);
206
+ assert.equal(status.status, 200);
207
+ const info = await status.json();
208
+ assert.equal(info.port, 47555);
209
+ assert.equal(info.listening, false);
210
+ assert.deepEqual(info.pids, []);
211
+
212
+ const aliasKill = await fetch(`${base}/api/ports/kill`, {
213
+ method: 'POST',
214
+ headers: json,
215
+ body: JSON.stringify({ port: 47555 }),
216
+ });
217
+ assert.equal(aliasKill.status, 200);
218
+ assert.deepEqual((await aliasKill.json()).pids, []);
219
+
220
+ const badStatus = await fetch(`${base}/api/ports/0`);
221
+ assert.equal(badStatus.status, 400);
222
+ } finally {
223
+ await instance.close();
224
+ }
225
+ });
226
+
227
+ test('runs an evaluation over HTTP and persists the run', async () => {
228
+ const model = http.createServer((req, res) => {
229
+ let body = '';
230
+ req.on('data', (c) => (body += c));
231
+ req.on('end', () => {
232
+ res.setHeader('Content-Type', 'text/event-stream');
233
+ res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: 'hola' } }] })}\n\n`);
234
+ res.write('data: [DONE]\n\n');
235
+ res.end();
236
+ });
237
+ });
238
+ await new Promise((r) => model.listen(0, '127.0.0.1', r));
239
+ const modelBase = `http://127.0.0.1:${model.address().port}/v1`;
240
+
241
+ const { instance, base } = await startServer();
242
+ try {
243
+ const res = await fetch(`${base}/api/eval`, {
244
+ method: 'POST',
245
+ headers: { 'Content-Type': 'application/json' },
246
+ body: JSON.stringify({
247
+ provider: 'openai-compatible',
248
+ baseUrl: modelBase,
249
+ models: ['mock'],
250
+ concurrency: 1,
251
+ tasks: [{ id: 't1', input: 'saluda', expect: { contains: 'hola' } }],
252
+ }),
253
+ });
254
+ const text = await res.text();
255
+ assert.match(text, /event: result/);
256
+ assert.match(text, /event: summary/);
257
+ assert.match(text, /"passed":1/);
258
+
259
+ const runs = await (await fetch(`${base}/api/runs`)).json();
260
+ assert.equal(runs.runs.length, 1);
261
+ const detail = await (await fetch(`${base}/api/runs/${runs.runs[0].id}`)).json();
262
+ assert.equal(detail.results.length, 1);
263
+ assert.equal(detail.results[0].passed, 1);
264
+ } finally {
265
+ await instance.close();
266
+ model.close();
267
+ }
268
+ });
269
+
270
+ test('creates a folder over HTTP and rejects bad input', async () => {
271
+ const root = await mkdtemp(path.join(tmpdir(), 'tamnora-newdir-'));
272
+ const { instance, base } = await startServer();
273
+ const json = { 'Content-Type': 'application/json' };
274
+ const create = (body) =>
275
+ fetch(`${base}/api/folders`, { method: 'POST', headers: json, body: JSON.stringify(body) });
276
+ try {
277
+ const created = await create({ parent: root, name: 'mi-proyecto' });
278
+ assert.equal(created.status, 201);
279
+ const data = await created.json();
280
+ assert.equal(data.path, path.join(root, 'mi-proyecto'));
281
+ assert.ok((await stat(data.path)).isDirectory());
282
+
283
+ assert.equal((await create({ parent: root, name: 'mi-proyecto' })).status, 409);
284
+ assert.equal((await create({ parent: root, name: '../escape' })).status, 400);
285
+ assert.equal((await create({ parent: root, name: '' })).status, 400);
286
+ assert.equal((await create({ name: 'sin-parent' })).status, 400);
287
+ } finally {
288
+ await instance.close();
289
+ }
290
+ });
291
+
292
+ test('lists and previews workspace files, confining to the root', async () => {
293
+ const root = await mkdtemp(path.join(tmpdir(), 'tamnora-files-'));
294
+ await mkdir(path.join(root, 'src'));
295
+ await mkdir(path.join(root, 'node_modules'));
296
+ await writeFile(path.join(root, 'README.md'), '# hola\n');
297
+ await writeFile(path.join(root, 'src', 'index.js'), 'export const x = 1;\n');
298
+ await writeFile(path.join(root, 'zeta.bin'), Buffer.from([1, 2, 0, 3]));
299
+
300
+ const { instance, base } = await startServer();
301
+ const list = (params) => fetch(`${base}/api/files?${new URLSearchParams(params)}`);
302
+ const content = (params) => fetch(`${base}/api/files/content?${new URLSearchParams(params)}`);
303
+ try {
304
+ const top = await (await list({ root, path: '' })).json();
305
+ assert.equal(top.path, '');
306
+ assert.deepEqual(
307
+ top.entries.map((e) => `${e.type}:${e.name}`),
308
+ ['dir:src', 'file:README.md', 'file:zeta.bin']
309
+ );
310
+
311
+ const sub = await (await list({ root, path: 'src' })).json();
312
+ assert.deepEqual(sub.entries.map((e) => e.name), ['index.js']);
313
+ assert.equal(sub.entries[0].size > 0, true);
314
+
315
+ const preview = await (await content({ root, path: 'src/index.js' })).json();
316
+ assert.equal(preview.content, 'export const x = 1;\n');
317
+
318
+ assert.equal((await list({ root, path: '../../' })).status, 400);
319
+ assert.equal((await list({ root: path.join(root, 'nope') })).status, 400);
320
+ assert.equal((await content({ root, path: 'zeta.bin' })).status, 415);
321
+ } finally {
322
+ await instance.close();
323
+ }
324
+ });
325
+
326
+ test('reports git status and per-file diffs', { skip: !HAS_GIT }, async () => {
327
+ const root = await mkdtemp(path.join(tmpdir(), 'tamnora-git-'));
328
+ gitInit(root);
329
+ await writeFile(path.join(root, 'app.js'), 'const a = 1;\n');
330
+ execFileSync('git', ['add', 'app.js'], { cwd: root });
331
+ execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: root });
332
+ await writeFile(path.join(root, 'app.js'), 'const a = 2;\n');
333
+ await writeFile(path.join(root, 'nuevo.txt'), 'hola\n');
334
+
335
+ const { instance, base } = await startServer();
336
+ try {
337
+ const status = await (
338
+ await fetch(`${base}/api/git/status?${new URLSearchParams({ cwd: root })}`)
339
+ ).json();
340
+ assert.equal(status.isRepo, true);
341
+ assert.ok(status.branch);
342
+ assert.deepEqual(
343
+ status.entries.map((e) => e.path).sort(),
344
+ ['app.js', 'nuevo.txt']
345
+ );
346
+ assert.equal(status.entries.find((e) => e.path === 'app.js').status, 'modified');
347
+ assert.equal(status.entries.find((e) => e.path === 'nuevo.txt').status, 'untracked');
348
+
349
+ const diff = await (
350
+ await fetch(`${base}/api/git/diff?${new URLSearchParams({ cwd: root, path: 'app.js' })}`)
351
+ ).json();
352
+ assert.match(diff.diff, /-const a = 1;/);
353
+ assert.match(diff.diff, /\+const a = 2;/);
354
+
355
+ const untracked = await (
356
+ await fetch(`${base}/api/git/diff?${new URLSearchParams({ cwd: root, path: 'nuevo.txt' })}`)
357
+ ).json();
358
+ assert.equal(untracked.untracked, true);
359
+ assert.match(untracked.diff, /\+hola/);
360
+
361
+ const outside = await fetch(
362
+ `${base}/api/git/diff?${new URLSearchParams({ cwd: root, path: '../fuera' })}`
363
+ );
364
+ assert.equal(outside.status, 400);
365
+
366
+ const plain = await mkdtemp(path.join(tmpdir(), 'tamnora-nogit-'));
367
+ const none = await (
368
+ await fetch(`${base}/api/git/status?${new URLSearchParams({ cwd: plain })}`)
369
+ ).json();
370
+ assert.equal(none.isRepo, false);
371
+ } finally {
372
+ await instance.close();
373
+ }
374
+ });
375
+
376
+ test('previews an HTML file through a static server', async () => {
377
+ const root = await mkdtemp(path.join(tmpdir(), 'tamnora-html-'));
378
+ await writeFile(path.join(root, 'index.html'), '<h1>hola</h1>');
379
+ const { instance, base } = await startServer();
380
+ const json = { 'Content-Type': 'application/json' };
381
+ try {
382
+ const res = await fetch(`${base}/api/files/preview`, {
383
+ method: 'POST',
384
+ headers: json,
385
+ body: JSON.stringify({ root, path: 'index.html' }),
386
+ });
387
+ assert.equal(res.status, 200);
388
+ const data = await res.json();
389
+ assert.match(data.url, /^http:\/\/127\.0\.0\.1:\d+\/index\.html$/);
390
+
391
+ const outside = await fetch(`${base}/api/files/preview`, {
392
+ method: 'POST',
393
+ headers: json,
394
+ body: JSON.stringify({ root, path: '../secret.txt' }),
395
+ });
396
+ assert.equal(outside.status, 400);
397
+ } finally {
398
+ await instance.close();
399
+ }
400
+ });
401
+
402
+ test('lists workspace folders and rejects invalid paths', async () => {
403
+ const root = await mkdtemp(path.join(tmpdir(), 'tamnora-folders-'));
404
+ await mkdir(path.join(root, 'beta'));
405
+ await mkdir(path.join(root, 'alpha'));
406
+ await writeFile(path.join(root, 'solo-archivo.txt'), 'x');
407
+
408
+ const { instance, base } = await startServer();
409
+ try {
410
+ const roots = await (await fetch(`${base}/api/folders`)).json();
411
+ assert.ok(roots.dirs.length >= 1);
412
+ assert.equal(roots.path, null);
413
+
414
+ const listed = await (await fetch(`${base}/api/folders?path=${encodeURIComponent(root)}`)).json();
415
+ assert.equal(listed.path, path.resolve(root));
416
+ assert.deepEqual(
417
+ listed.dirs.map((d) => d.name),
418
+ ['alpha', 'beta']
419
+ );
420
+ assert.ok(listed.parent);
421
+
422
+ const missing = await fetch(`${base}/api/folders?path=${encodeURIComponent(path.join(root, 'nope'))}`);
423
+ assert.equal(missing.status, 400);
424
+
425
+ const file = await fetch(`${base}/api/folders?path=${encodeURIComponent(path.join(root, 'solo-archivo.txt'))}`);
426
+ assert.equal(file.status, 400);
427
+ } finally {
428
+ await instance.close();
429
+ }
430
+ });
431
+
432
+ test('requires login and CSRF when a user is configured', async () => {
433
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-auth-'));
434
+ setHome(cwd);
435
+ const app = ensureApp();
436
+ app.user = makeUser('admin', 'secreto123');
437
+ writeApp(app);
438
+ assert.equal(readApp().user.name, 'admin');
439
+
440
+ const instance = await createTamnoraServer({
441
+ cwd,
442
+ dbPath: path.join(cwd, 'tamnora.db'),
443
+ loadMcp: false,
444
+ resolveProvider: () => ({ provider: fakeProvider, config: {} }),
445
+ });
446
+ const info = await instance.listen({ port: 0 });
447
+ const base = info.url;
448
+ try {
449
+ const unauth = await fetch(`${base}/api/providers`);
450
+ assert.equal(unauth.status, 401);
451
+
452
+ const bad = await fetch(`${base}/api/auth/login`, {
453
+ method: 'POST',
454
+ headers: { 'Content-Type': 'application/json' },
455
+ body: JSON.stringify({ username: 'admin', password: 'nope' }),
456
+ });
457
+ assert.equal(bad.status, 401);
458
+
459
+ const login = await fetch(`${base}/api/auth/login`, {
460
+ method: 'POST',
461
+ headers: { 'Content-Type': 'application/json' },
462
+ body: JSON.stringify({ username: 'admin', password: 'secreto123' }),
463
+ });
464
+ assert.equal(login.status, 200);
465
+ const cookie = login.headers.get('set-cookie');
466
+ assert.ok(cookie);
467
+ const csrf = (await login.json()).csrf;
468
+ assert.ok(csrf);
469
+
470
+ const authed = await fetch(`${base}/api/providers`, { headers: { cookie } });
471
+ assert.equal(authed.status, 200);
472
+
473
+ const noCsrf = await fetch(`${base}/api/settings`, {
474
+ method: 'PUT',
475
+ headers: { 'Content-Type': 'application/json', cookie },
476
+ body: JSON.stringify({ provider: 'openrouter' }),
477
+ });
478
+ assert.equal(noCsrf.status, 403);
479
+
480
+ const withCsrf = await fetch(`${base}/api/settings`, {
481
+ method: 'PUT',
482
+ headers: { 'Content-Type': 'application/json', cookie, 'x-csrf': csrf },
483
+ body: JSON.stringify({ provider: 'openrouter' }),
484
+ });
485
+ assert.equal(withCsrf.status, 200);
486
+ } finally {
487
+ await instance.close();
488
+ }
489
+ });
490
+
491
+ test('serves browser screenshots from the home dir, confined to png names', async () => {
492
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-shots-'));
493
+ const { instance, base } = await startServer({ cwd });
494
+ try {
495
+ const dir = screenshotsDir();
496
+ await mkdir(dir, { recursive: true });
497
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
498
+ await writeFile(path.join(dir, 'abc-123.png'), png);
499
+
500
+ const ok = await fetch(`${base}/api/screenshots/abc-123.png`);
501
+ assert.equal(ok.status, 200);
502
+ assert.equal(ok.headers.get('content-type'), 'image/png');
503
+ assert.equal((await ok.arrayBuffer()).byteLength, png.length);
504
+
505
+ assert.equal((await fetch(`${base}/api/screenshots/bad.txt`)).status, 400);
506
+ assert.equal((await fetch(`${base}/api/screenshots/missing.png`)).status, 404);
507
+ } finally {
508
+ await instance.close();
509
+ }
510
+ });
511
+