@danieltmn/tamnora 0.10.0 → 0.11.1
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 +20 -0
- package/README.md +7 -14
- package/SECURITY.md +4 -6
- package/harness/packages/cli/src/cli.js +1 -1
- package/harness/packages/cli/src/commands/bridge.js +51 -210
- package/harness/packages/cli/test/tunnel.test.js +5 -1
- package/harness/packages/core/src/config/app.js +4 -6
- package/harness/packages/core/src/index.js +0 -1
- package/harness/packages/core/src/tunnel.js +2 -154
- package/harness/packages/core/test/home.test.js +7 -5
- package/harness/packages/server/public/app.js +23 -42
- package/harness/packages/server/public/index.html +1 -1
- package/harness/packages/server/public/styles.css +53 -2
- package/package.json +1 -1
- package/harness/packages/server/public/relayClient.js +0 -170
- package/harness/packages/server/test/relay.test.js +0 -219
|
@@ -6,11 +6,7 @@ import { pidAlive, killPidTree } from './pid.js';
|
|
|
6
6
|
/**
|
|
7
7
|
* Acceso remoto. Proveedores:
|
|
8
8
|
* - tailscale: VPN mesh privada + `tailscale serve` (recomendado; sin exposicion).
|
|
9
|
-
* - tunnelmole: tunel publico gratis (URL aleatoria)
|
|
10
|
-
* - relay: mailbox HTTP propio (harness.tamnora.com) — LEGACY.
|
|
11
|
-
*
|
|
12
|
-
* Los tuneles publicos se retiran en la proxima version. `tunnelmole` se
|
|
13
|
-
* conserva solo para el feature de `preview`; `startRelay` esta condenado.
|
|
9
|
+
* - tunnelmole: tunel publico gratis (URL aleatoria); solo para `preview`.
|
|
14
10
|
*/
|
|
15
11
|
|
|
16
12
|
function killTree(child) {
|
|
@@ -116,7 +112,7 @@ function spawnTunnel(bin, args, opts = {}) {
|
|
|
116
112
|
});
|
|
117
113
|
}
|
|
118
114
|
|
|
119
|
-
//
|
|
115
|
+
// Tunel publico para el feature `preview` (no lo usa `bridge`).
|
|
120
116
|
export function startTunnelmole(port, opts = {}) {
|
|
121
117
|
return spawnTunnel('npx', ['--yes', 'tunnelmole', String(port)], {
|
|
122
118
|
...opts,
|
|
@@ -127,13 +123,6 @@ export function startTunnelmole(port, opts = {}) {
|
|
|
127
123
|
});
|
|
128
124
|
}
|
|
129
125
|
|
|
130
|
-
/**
|
|
131
|
-
* Relay propio (harness.tamnora.com): mailbox HTTP sobre cPanel (solo PHP).
|
|
132
|
-
* El daemon no recibe conexiones: llama hacia afuera (a=poll), ejecuta cada
|
|
133
|
-
* request contra el server local y empuja las respuestas (a=push).
|
|
134
|
-
*
|
|
135
|
-
* LEGACY / condenado: se retira en la proxima version (usar tailscale).
|
|
136
|
-
*/
|
|
137
126
|
/**
|
|
138
127
|
* Tailscale: VPN mesh (WireGuard). `tailscale serve` publica HTTPS dentro del
|
|
139
128
|
* tailnet reenviando a loopback, asi que no hay exposicion publica y las
|
|
@@ -245,152 +234,11 @@ export function resetTailscaleServe() {
|
|
|
245
234
|
return runTailscale(['serve', 'reset'], { timeoutMs: 30000 });
|
|
246
235
|
}
|
|
247
236
|
|
|
248
|
-
export function startRelay(port, opts = {}) {
|
|
249
|
-
const relayUrl = String(opts.relayUrl ?? '').replace(/\/+$/, '');
|
|
250
|
-
const token = String(opts.token ?? '');
|
|
251
|
-
if (!relayUrl || !/^https?:\/\//.test(relayUrl)) {
|
|
252
|
-
throw new Error('relay: falta tunnel.relayUrl (https://harness.tamnora.com/index.php)');
|
|
253
|
-
}
|
|
254
|
-
if (!token) throw new Error('relay: falta tunnel.token');
|
|
255
|
-
const log = opts.log ?? (() => {});
|
|
256
|
-
const local = `http://127.0.0.1:${port}`;
|
|
257
|
-
const state = { stopped: false };
|
|
258
|
-
|
|
259
|
-
const call = async (action, body = {}, { timeoutMs = 30000 } = {}) => {
|
|
260
|
-
const res = await fetch(`${relayUrl}?a=${action}`, {
|
|
261
|
-
method: 'POST',
|
|
262
|
-
headers: { 'Content-Type': 'application/json' },
|
|
263
|
-
body: JSON.stringify({ token, ...body }),
|
|
264
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
265
|
-
});
|
|
266
|
-
if (!res.ok) throw new Error(`relay ${action}: HTTP ${res.status}`);
|
|
267
|
-
return res.json();
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
async function push(rid, chunks) {
|
|
271
|
-
await call('push', { rid, chunks }, { timeoutMs: 60000 });
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
async function handleRequest(row) {
|
|
275
|
-
const envelope = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
|
|
276
|
-
const method = envelope.method ?? 'GET';
|
|
277
|
-
const path = String(envelope.path ?? '/');
|
|
278
|
-
const target = `${local}${path.startsWith('/') ? path : `/${path}`}`;
|
|
279
|
-
const init = { method, headers: { ...envelope.headers } };
|
|
280
|
-
delete init.headers.host;
|
|
281
|
-
if (envelope.body != null) init.body = envelope.body;
|
|
282
|
-
const res = await fetch(target, init);
|
|
283
|
-
const contentType = String(res.headers.get('content-type') ?? '');
|
|
284
|
-
const flat = {};
|
|
285
|
-
for (const header of ['content-type']) {
|
|
286
|
-
const value = res.headers.get(header);
|
|
287
|
-
if (value) flat[header] = value;
|
|
288
|
-
}
|
|
289
|
-
const cookies = res.headers.getSetCookie?.() ?? [];
|
|
290
|
-
if (cookies.length) flat['set-cookie'] = cookies;
|
|
291
|
-
|
|
292
|
-
if (!contentType.includes('text/event-stream')) {
|
|
293
|
-
const buffer = Buffer.from(await res.arrayBuffer());
|
|
294
|
-
await push(row.id, [
|
|
295
|
-
{
|
|
296
|
-
status: res.status,
|
|
297
|
-
headers: flat,
|
|
298
|
-
body: buffer.toString('base64'),
|
|
299
|
-
done: true,
|
|
300
|
-
},
|
|
301
|
-
]);
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// SSE: reenviamos cada frame tal cual (el cliente sabe reconectar con cursor).
|
|
306
|
-
let seq = 0;
|
|
307
|
-
await push(row.id, [{ seq: seq++, status: res.status, headers: flat, done: false }]);
|
|
308
|
-
const reader = res.body.getReader();
|
|
309
|
-
const decoder = new TextDecoder();
|
|
310
|
-
let buffer = '';
|
|
311
|
-
for (;;) {
|
|
312
|
-
const { value, done } = await reader.read();
|
|
313
|
-
if (done) break;
|
|
314
|
-
buffer += decoder.decode(value, { stream: true });
|
|
315
|
-
let index;
|
|
316
|
-
while ((index = buffer.indexOf('\n\n')) !== -1) {
|
|
317
|
-
const frame = buffer.slice(0, index);
|
|
318
|
-
buffer = buffer.slice(index + 2);
|
|
319
|
-
let ev = '';
|
|
320
|
-
let data = '';
|
|
321
|
-
let id = null;
|
|
322
|
-
for (const line of frame.split('\n')) {
|
|
323
|
-
if (line.startsWith('event:')) ev = line.slice(6).trim();
|
|
324
|
-
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
|
325
|
-
else if (line.startsWith('id:')) id = line.slice(3).trim();
|
|
326
|
-
}
|
|
327
|
-
if (!ev && !data) continue;
|
|
328
|
-
await push(row.id, [{ seq: seq++, ev: ev || 'message', data, id, done: false }]);
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
await push(row.id, [{ done: true }]);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
async function pump() {
|
|
335
|
-
const registered = await call('register', {}, { timeoutMs: 20000 });
|
|
336
|
-
let cursor = Number(registered.cursor) || 0;
|
|
337
|
-
log(`relay: conectado a ${relayUrl} (inbox desde ${cursor})`);
|
|
338
|
-
while (!state.stopped) {
|
|
339
|
-
let rows;
|
|
340
|
-
try {
|
|
341
|
-
const batch = await call('poll', { after: cursor, wait: 20 }, { timeoutMs: 40000 });
|
|
342
|
-
rows = batch.rows ?? [];
|
|
343
|
-
} catch (error) {
|
|
344
|
-
if (state.stopped) break;
|
|
345
|
-
log(`relay poll: ${error?.message ?? error}`);
|
|
346
|
-
await new Promise((r) => setTimeout(r, 3000));
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
for (const row of rows) {
|
|
350
|
-
cursor = Math.max(cursor, Number(row.id) || 0);
|
|
351
|
-
try {
|
|
352
|
-
await handleRequest(row);
|
|
353
|
-
} catch (error) {
|
|
354
|
-
log(`relay request ${row.id}: ${error?.message ?? error}`);
|
|
355
|
-
try {
|
|
356
|
-
await push(row.id, [
|
|
357
|
-
{
|
|
358
|
-
status: 502,
|
|
359
|
-
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
360
|
-
body: Buffer.from(JSON.stringify({ error: `harness inaccesible: ${error?.message ?? error}` })).toString('base64'),
|
|
361
|
-
done: true,
|
|
362
|
-
},
|
|
363
|
-
]);
|
|
364
|
-
} catch {
|
|
365
|
-
/* nada */
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
pump().catch((error) => {
|
|
373
|
-
if (!state.stopped) log(`relay pump: ${error?.message ?? error}`);
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
return {
|
|
377
|
-
url: relayUrl,
|
|
378
|
-
provider: 'relay',
|
|
379
|
-
pid: 0,
|
|
380
|
-
child: null,
|
|
381
|
-
probeUrl: `${relayUrl}?a=status`,
|
|
382
|
-
stop() {
|
|
383
|
-
state.stopped = true;
|
|
384
|
-
},
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
|
|
388
237
|
/** Devuelve { url, provider, pid, stop }. provider 'none' no abre nada. */
|
|
389
238
|
export async function startTunnel(port, provider = 'tunnelmole', opts = {}) {
|
|
390
239
|
const p = String(provider || 'tunnelmole').toLowerCase();
|
|
391
240
|
if (p === 'none' || p === 'off' || p === '') return { url: '', provider: 'none', pid: 0, stop: () => {} };
|
|
392
241
|
if (p === 'tailscale' || p === 'ts') return startTailscaleServe(port, opts);
|
|
393
242
|
if (p === 'tunnelmole' || p === 'tmole') return startTunnelmole(port, opts);
|
|
394
|
-
if (p === 'relay') return startRelay(port, opts);
|
|
395
243
|
throw new Error(`proveedor de tunel no soportado: ${provider}`);
|
|
396
244
|
}
|
|
@@ -48,13 +48,15 @@ test('ensureApp creates app.json with a secret and roundtrips', async () => {
|
|
|
48
48
|
assert.equal(readApp().port, 4599);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
test('readApp migra
|
|
51
|
+
test('readApp migra los proveedores retirados (tunnelmole/relay) a tailscale', async () => {
|
|
52
52
|
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-home-'));
|
|
53
53
|
setHome(dir);
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
for (const provider of ['tunnelmole', 'relay']) {
|
|
55
|
+
const app = ensureApp();
|
|
56
|
+
app.tunnel = { ...app.tunnel, provider };
|
|
57
|
+
writeApp(app);
|
|
58
|
+
assert.equal(readApp().tunnel.provider, 'tailscale', provider);
|
|
59
|
+
}
|
|
58
60
|
});
|
|
59
61
|
|
|
60
62
|
test('migrateLegacyDb copies the legacy project database to the global home', async () => {
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import { renderMarkdown, speechText } from './markdown.js';
|
|
4
4
|
import { streamSse, followRun } from './runStream.js';
|
|
5
5
|
import { diffState, pollDelayMs, localState, summarizeArgs } from './reconcile.js';
|
|
6
|
-
import { installRelayTransport, showRelayTokenGate } from './relayClient.js';
|
|
7
6
|
|
|
8
7
|
const $ = (sel) => document.querySelector(sel);
|
|
9
8
|
let csrfToken = '';
|
|
@@ -97,30 +96,7 @@ function icon(name, size = 16, className = '') {
|
|
|
97
96
|
|
|
98
97
|
/* --- HTTP / SSE ---------------------------------------------------------- */
|
|
99
98
|
|
|
100
|
-
// Tamnora Relay: cuando la consola se sirve desde harness.tamnora.com/console,
|
|
101
|
-
// el page define `window.TAMNORA_RELAY = { base }` y todo el trafico /api pasa
|
|
102
|
-
// por el mailbox del relay (relaClient). Sin esa variable es el harness local.
|
|
103
|
-
const RELAY_CONFIG = window.TAMNORA_RELAY ?? null;
|
|
104
|
-
const relayToken = RELAY_CONFIG
|
|
105
|
-
? new URLSearchParams(location.search).get('t') || localStorage.getItem('tamnora-relay-token') || ''
|
|
106
|
-
: '';
|
|
107
|
-
const relayTransport = RELAY_CONFIG
|
|
108
|
-
? installRelayTransport({ base: RELAY_CONFIG.base, token: relayToken })
|
|
109
|
-
: null;
|
|
110
|
-
|
|
111
|
-
if (RELAY_CONFIG && !relayTransport.enabled) {
|
|
112
|
-
showRelayTokenGate();
|
|
113
|
-
throw new Error('[relay] falta el token de acceso');
|
|
114
|
-
}
|
|
115
|
-
|
|
116
99
|
async function api(path, { method = 'GET', body, headers = {} } = {}) {
|
|
117
|
-
if (relayTransport?.enabled) {
|
|
118
|
-
return relayTransport.request(path, {
|
|
119
|
-
method,
|
|
120
|
-
body,
|
|
121
|
-
headers: method !== 'GET' && csrfToken ? { 'x-csrf': csrfToken, ...headers } : headers,
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
100
|
const init = {};
|
|
125
101
|
if (body) init['Content-Type'] = 'application/json';
|
|
126
102
|
if (method !== 'GET' && csrfToken) init['x-csrf'] = csrfToken;
|
|
@@ -145,13 +121,10 @@ async function api(path, { method = 'GET', body, headers = {} } = {}) {
|
|
|
145
121
|
}
|
|
146
122
|
|
|
147
123
|
const streamPost = (url, body, onEvent, signal) =>
|
|
148
|
-
|
|
149
|
-
? relayTransport.stream(url, { method: 'POST', body, signal, headers: csrfToken ? { 'x-csrf': csrfToken } : {} }, onEvent)
|
|
150
|
-
: streamSse(url, { method: 'POST', body, csrf: csrfToken, signal }, onEvent);
|
|
124
|
+
streamSse(url, { method: 'POST', body, csrf: csrfToken, signal }, onEvent);
|
|
151
125
|
|
|
152
126
|
/** SSE GET de la consola (/api/sessions/:id/stream, /api/events). */
|
|
153
|
-
const openStream = (url, opts, onEvent) =>
|
|
154
|
-
relayTransport?.enabled ? relayTransport.stream(url, opts, onEvent) : streamSse(url, opts, onEvent);
|
|
127
|
+
const openStream = (url, opts, onEvent) => streamSse(url, opts, onEvent);
|
|
155
128
|
|
|
156
129
|
/* --- localStorage (todas las lecturas/escrituras pasan por aca) ------------ */
|
|
157
130
|
|
|
@@ -601,7 +574,7 @@ if (logout) {
|
|
|
601
574
|
} catch {
|
|
602
575
|
// igual salimos
|
|
603
576
|
}
|
|
604
|
-
location.href =
|
|
577
|
+
location.href = '/landing';
|
|
605
578
|
});
|
|
606
579
|
}
|
|
607
580
|
|
|
@@ -3604,9 +3577,11 @@ function renderProviderList() {
|
|
|
3604
3577
|
)
|
|
3605
3578
|
);
|
|
3606
3579
|
li.appendChild(main);
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3580
|
+
const actions = el('div', 'pactions');
|
|
3581
|
+
actions.appendChild(button('usar', (event) => { event.stopPropagation(); useProvider(p.id); }));
|
|
3582
|
+
actions.appendChild(button('editar', (event) => { event.stopPropagation(); openProviderForm(p); }));
|
|
3583
|
+
if (p.stored) actions.appendChild(button('borrar', (event) => { event.stopPropagation(); removeProvider(p.id); }, 'btn danger'));
|
|
3584
|
+
li.appendChild(actions);
|
|
3610
3585
|
li.addEventListener('click', () => openProviderForm(p));
|
|
3611
3586
|
providerListEl.appendChild(li);
|
|
3612
3587
|
}
|
|
@@ -3806,9 +3781,11 @@ async function refreshMcp({ connect = false } = {}) {
|
|
|
3806
3781
|
}
|
|
3807
3782
|
main.append(title, el('div', 'pmeta', parts.join(' · ')));
|
|
3808
3783
|
li.appendChild(main);
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3784
|
+
const actions = el('div', 'pactions');
|
|
3785
|
+
actions.appendChild(button(s.enabled ? 'deshabilitar' : 'habilitar', (event) => { event.stopPropagation(); toggleMcp(s); }));
|
|
3786
|
+
actions.appendChild(button('editar', (event) => { event.stopPropagation(); openMcpForm(s); }));
|
|
3787
|
+
if (s.source !== 'config') actions.appendChild(button('borrar', (event) => { event.stopPropagation(); removeMcp(s.id); }, 'btn danger'));
|
|
3788
|
+
li.appendChild(actions);
|
|
3812
3789
|
li.addEventListener('click', () => openMcpForm(s));
|
|
3813
3790
|
mcpListEl.appendChild(li);
|
|
3814
3791
|
}
|
|
@@ -3922,9 +3899,11 @@ async function refreshSkills() {
|
|
|
3922
3899
|
el('div', 'pmeta', s.description || s.when || '(sin descripción)')
|
|
3923
3900
|
);
|
|
3924
3901
|
li.appendChild(main);
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3902
|
+
const actions = el('div', 'pactions');
|
|
3903
|
+
actions.appendChild(button(s.enabled ? 'deshabilitar' : 'habilitar', (event) => { event.stopPropagation(); toggleSkill(s.id); }));
|
|
3904
|
+
actions.appendChild(button('editar', (event) => { event.stopPropagation(); openSkillForm(s.id); }));
|
|
3905
|
+
if (s.removable) actions.appendChild(button('borrar', (event) => { event.stopPropagation(); removeSkill(s.id); }, 'btn danger'));
|
|
3906
|
+
li.appendChild(actions);
|
|
3928
3907
|
li.addEventListener('click', () => openSkillForm(s.id));
|
|
3929
3908
|
skillListEl.appendChild(li);
|
|
3930
3909
|
}
|
|
@@ -5200,9 +5179,11 @@ async function refreshProcs() {
|
|
|
5200
5179
|
el('div', 'pmeta', `${proc.status}${proc.port ? ` · puerto ${proc.port}` : ''}`)
|
|
5201
5180
|
);
|
|
5202
5181
|
li.appendChild(main);
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
if (proc.
|
|
5182
|
+
const actions = el('div', 'pactions');
|
|
5183
|
+
actions.appendChild(button('logs', () => { selectedProc = proc.id; refreshProcLog(); }));
|
|
5184
|
+
if (proc.port) actions.appendChild(button('preview', () => { setExplorerMode('preview'); loadPreview(`http://127.0.0.1:${proc.port}`); }));
|
|
5185
|
+
if (proc.running) actions.appendChild(button('detener', () => stopProc(proc.id), 'btn danger'));
|
|
5186
|
+
li.appendChild(actions);
|
|
5206
5187
|
procsListEl.appendChild(li);
|
|
5207
5188
|
}
|
|
5208
5189
|
}
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
</head>
|
|
38
38
|
<body>
|
|
39
39
|
<header class="topbar">
|
|
40
|
-
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span class="ver">v0.
|
|
40
|
+
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span class="ver">v0.11.1</span></div>
|
|
41
41
|
<nav class="tabs">
|
|
42
42
|
<button class="tab active" data-tab="chat">chat</button>
|
|
43
43
|
<button class="tab" data-tab="providers">proveedores</button>
|
|
@@ -1220,6 +1220,16 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
|
|
|
1220
1220
|
.providers .pid { font-weight: 700; }
|
|
1221
1221
|
.providers .pmeta { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1222
1222
|
.providers .grow { flex: 1; min-width: 0; }
|
|
1223
|
+
.providers .pactions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
|
|
1224
|
+
.providers li.muted {
|
|
1225
|
+
border: 0;
|
|
1226
|
+
background: none;
|
|
1227
|
+
backdrop-filter: none;
|
|
1228
|
+
box-shadow: none;
|
|
1229
|
+
cursor: default;
|
|
1230
|
+
padding-left: 2px;
|
|
1231
|
+
}
|
|
1232
|
+
.providers li.muted:hover { border: 0; }
|
|
1223
1233
|
|
|
1224
1234
|
.summary { color: var(--muted); }
|
|
1225
1235
|
.summary.ok { color: var(--ok); }
|
|
@@ -1598,17 +1608,38 @@ dialog.file-modal.preview-maximized {
|
|
|
1598
1608
|
}
|
|
1599
1609
|
.composer {
|
|
1600
1610
|
margin: 8px 10px 6px;
|
|
1601
|
-
padding:
|
|
1611
|
+
padding: 9px 10px;
|
|
1602
1612
|
border-radius: 18px;
|
|
1603
1613
|
}
|
|
1614
|
+
/* En un movil el input flotaba sin fondo y casi no se notaba: lo sacamos a su
|
|
1615
|
+
propia linea, con campo visible y 16px (evita el zoom de iOS al enfocar). */
|
|
1616
|
+
.composer-row {
|
|
1617
|
+
flex-wrap: wrap;
|
|
1618
|
+
align-items: center;
|
|
1619
|
+
gap: 8px;
|
|
1620
|
+
}
|
|
1604
1621
|
.composer textarea {
|
|
1622
|
+
order: -1;
|
|
1623
|
+
flex: 1 0 100%;
|
|
1605
1624
|
max-height: 40vh;
|
|
1625
|
+
min-height: 46px;
|
|
1626
|
+
padding: 11px 13px;
|
|
1627
|
+
border: 1px solid var(--border);
|
|
1628
|
+
border-radius: 14px;
|
|
1629
|
+
background: var(--bg);
|
|
1630
|
+
font-size: 16px;
|
|
1631
|
+
line-height: 1.45;
|
|
1632
|
+
}
|
|
1633
|
+
.composer textarea:focus {
|
|
1634
|
+
border: 1px solid var(--accent);
|
|
1635
|
+
box-shadow: 0 0 0 4px var(--accent-soft);
|
|
1606
1636
|
}
|
|
1607
1637
|
.ctrl-pill {
|
|
1608
1638
|
max-width: 46vw;
|
|
1609
1639
|
}
|
|
1610
1640
|
.model-pill {
|
|
1611
|
-
max-width:
|
|
1641
|
+
max-width: 46vw;
|
|
1642
|
+
margin-left: auto;
|
|
1612
1643
|
}
|
|
1613
1644
|
.composer-status {
|
|
1614
1645
|
flex-wrap: wrap;
|
|
@@ -1617,6 +1648,26 @@ dialog.file-modal.preview-maximized {
|
|
|
1617
1648
|
.composer-status .spacer {
|
|
1618
1649
|
display: none;
|
|
1619
1650
|
}
|
|
1651
|
+
/* MCP / skills / proveedores / procesos: el titulo se apretaba contra los
|
|
1652
|
+
botones y la descripcion quedaba cortada. Pasamos las acciones a su propia
|
|
1653
|
+
linea y dejamos que el texto respire. */
|
|
1654
|
+
.providers li {
|
|
1655
|
+
flex-wrap: wrap;
|
|
1656
|
+
align-items: flex-start;
|
|
1657
|
+
gap: 8px 10px;
|
|
1658
|
+
padding: 11px 12px;
|
|
1659
|
+
}
|
|
1660
|
+
.providers .pactions {
|
|
1661
|
+
flex: 0 0 100%;
|
|
1662
|
+
justify-content: flex-end;
|
|
1663
|
+
}
|
|
1664
|
+
.providers .grow .pmeta {
|
|
1665
|
+
white-space: normal;
|
|
1666
|
+
overflow: hidden;
|
|
1667
|
+
display: -webkit-box;
|
|
1668
|
+
-webkit-line-clamp: 2;
|
|
1669
|
+
-webkit-box-orient: vertical;
|
|
1670
|
+
}
|
|
1620
1671
|
.empty-brand {
|
|
1621
1672
|
font-size: clamp(30px, 12vw, 46px);
|
|
1622
1673
|
}
|
package/package.json
CHANGED
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
// Transporte relay para la consola estatica (harness.tamnora.com/console/).
|
|
2
|
-
//
|
|
3
|
-
// La misma consola corre en el harness local y en el tamnora del sitio: aqui
|
|
4
|
-
// cada llamada a `api()` y cada stream se manda por el mailbox del relay y el
|
|
5
|
-
// daemon local (`tamnora bridge --tunnel relay`) la ejecuta contra su propio
|
|
6
|
-
// server. El token (?t=...) es la credencial compartida con el mailbox.
|
|
7
|
-
//
|
|
8
|
-
// Requiere runStream.js (streamSse/delay) que ya carga la consola.
|
|
9
|
-
|
|
10
|
-
import { streamSse, delay } from './runStream.js';
|
|
11
|
-
|
|
12
|
-
export function installRelayTransport({ base, token }) {
|
|
13
|
-
const cleanToken = String(token ?? '').trim();
|
|
14
|
-
if (!cleanToken) {
|
|
15
|
-
console.warn('[relay] sin token: la consola vera solo la pantalla de login');
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const b64decode = (value) =>
|
|
19
|
-
new TextDecoder().decode(Uint8Array.from(atob(String(value ?? '')), (c) => c.charCodeAt(0)));
|
|
20
|
-
|
|
21
|
-
const relayUrl = (action) => `${base}?a=${action}&t=${encodeURIComponent(cleanToken)}`;
|
|
22
|
-
|
|
23
|
-
async function post(action, payload, signal) {
|
|
24
|
-
const res = await fetch(relayUrl(action), {
|
|
25
|
-
method: 'POST',
|
|
26
|
-
headers: { 'Content-Type': 'application/json' },
|
|
27
|
-
body: JSON.stringify(payload),
|
|
28
|
-
signal,
|
|
29
|
-
});
|
|
30
|
-
if (!res.ok) {
|
|
31
|
-
const error = new Error(`relay HTTP ${res.status}`);
|
|
32
|
-
error.status = res.status;
|
|
33
|
-
throw error;
|
|
34
|
-
}
|
|
35
|
-
return res.json();
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function applyCookies(headers) {
|
|
39
|
-
for (const cookie of headers?.['set-cookie'] ?? []) {
|
|
40
|
-
const kv = String(cookie).split(';')[0];
|
|
41
|
-
if (kv && kv.includes('=')) document.cookie = `${kv}; Path=/`;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Request sincrono: PHP espera inline la respuesta completa del mailbox. */
|
|
46
|
-
async function request(path, { method = 'GET', body, headers = {} } = {}) {
|
|
47
|
-
const envelope = {
|
|
48
|
-
method,
|
|
49
|
-
path,
|
|
50
|
-
headers,
|
|
51
|
-
body: body === undefined ? null : JSON.stringify(body),
|
|
52
|
-
stream: false,
|
|
53
|
-
};
|
|
54
|
-
const data = await post('send', envelope);
|
|
55
|
-
if (data.error) {
|
|
56
|
-
const error = new Error(data.error);
|
|
57
|
-
error.status = Number(data.status) || 0;
|
|
58
|
-
throw error;
|
|
59
|
-
}
|
|
60
|
-
applyCookies(data.headers);
|
|
61
|
-
const text = b64decode(data.body);
|
|
62
|
-
let parsed = null;
|
|
63
|
-
try {
|
|
64
|
-
parsed = JSON.parse(text);
|
|
65
|
-
} catch {
|
|
66
|
-
parsed = { raw: text };
|
|
67
|
-
}
|
|
68
|
-
if (Number(data.status) >= 400) {
|
|
69
|
-
const error = new Error(parsed?.error ?? `HTTP ${data.status}`);
|
|
70
|
-
error.status = Number(data.status);
|
|
71
|
-
throw error;
|
|
72
|
-
}
|
|
73
|
-
return parsed;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const parseOrRaw = (text) => {
|
|
77
|
-
try {
|
|
78
|
-
return JSON.parse(text);
|
|
79
|
-
} catch {
|
|
80
|
-
return { raw: text };
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Stream via mailbox: abre el rid y consume `a=stream` (SSE con cursor).
|
|
86
|
-
* Si el PHP corta (max_execution_time) reconecta y repite desde el cursor:
|
|
87
|
-
* el replay es del mailbox, NO re-ejecuta el request original.
|
|
88
|
-
*/
|
|
89
|
-
async function stream(path, { method = 'POST', body, headers = {}, signal, silenceMs } = {}, onEvent) {
|
|
90
|
-
const envelope = {
|
|
91
|
-
method,
|
|
92
|
-
path,
|
|
93
|
-
headers,
|
|
94
|
-
body: body === undefined ? null : JSON.stringify(body),
|
|
95
|
-
stream: true,
|
|
96
|
-
};
|
|
97
|
-
const opened = await post('send', envelope, signal);
|
|
98
|
-
if (opened.error) {
|
|
99
|
-
const error = new Error(opened.error);
|
|
100
|
-
error.status = Number(opened.status) || 0;
|
|
101
|
-
throw error;
|
|
102
|
-
}
|
|
103
|
-
const rid = opened.rid;
|
|
104
|
-
let cursor = 0;
|
|
105
|
-
let attempt = 0;
|
|
106
|
-
for (;;) {
|
|
107
|
-
if (signal?.aborted) {
|
|
108
|
-
const error = new Error('Aborted', 'AbortError');
|
|
109
|
-
error.name = 'AbortError';
|
|
110
|
-
throw error;
|
|
111
|
-
}
|
|
112
|
-
try {
|
|
113
|
-
let finished = false;
|
|
114
|
-
await streamSse(
|
|
115
|
-
`${base}?a=stream&t=${encodeURIComponent(cleanToken)}&rid=${encodeURIComponent(rid)}&from=${cursor}`,
|
|
116
|
-
{ signal, silenceMs },
|
|
117
|
-
(event, data, id) => {
|
|
118
|
-
if (id != null) cursor = Number(id) || cursor;
|
|
119
|
-
if (event === 'chunk') {
|
|
120
|
-
const payload = data?.payload ?? {};
|
|
121
|
-
if (!payload.done) {
|
|
122
|
-
onEvent(payload.ev || 'message', payload.data != null ? parseOrRaw(payload.data) : {}, payload.id ?? null);
|
|
123
|
-
}
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
// meta: headers del run o cierre del daemon.
|
|
127
|
-
if (data?.payload?.done) finished = true;
|
|
128
|
-
else if (data?.payload?.error) {
|
|
129
|
-
const error = new Error(data.payload.error);
|
|
130
|
-
error.fatalRelay = true;
|
|
131
|
-
throw error;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
);
|
|
135
|
-
return { finished };
|
|
136
|
-
} catch (error) {
|
|
137
|
-
if (error?.name === 'AbortError' || error?.fatalRelay) throw error;
|
|
138
|
-
attempt += 1;
|
|
139
|
-
await delay(Math.min(8000, 1000 * 2 ** (attempt - 1)), signal);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
window.relayTransport = { token: cleanToken, base, request, stream, enabled: Boolean(cleanToken) };
|
|
145
|
-
return window.relayTransport;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Pantalla minima si no hay token: pide uno y recarga. */
|
|
149
|
-
export function showRelayTokenGate() {
|
|
150
|
-
document.body.innerHTML = '';
|
|
151
|
-
document.body.style.visibility = 'visible';
|
|
152
|
-
const form = document.createElement('form');
|
|
153
|
-
form.style.cssText = 'max-width:420px;margin:15vh auto;font-family:ui-sans-serif,system-ui;padding:0 16px';
|
|
154
|
-
form.innerHTML = `
|
|
155
|
-
<h1 style="font-size:20px">Tamnora Console</h1>
|
|
156
|
-
<p style="color:#777;font-size:14px">Pegá el token del bridge (o escaneá el QR del <code>tamnora bridge --tunnel relay</code>).</p>
|
|
157
|
-
<input name="t" placeholder="token" style="width:100%;padding:10px;font-size:14px" autocomplete="off" />
|
|
158
|
-
<button style="margin-top:10px;padding:10px 16px">entrar</button>`;
|
|
159
|
-
form.addEventListener('submit', (event) => {
|
|
160
|
-
event.preventDefault();
|
|
161
|
-
const value = form.elements.t.value.trim();
|
|
162
|
-
if (!value) return;
|
|
163
|
-
localStorage.setItem('tamnora-relay-token', value);
|
|
164
|
-
const params = new URLSearchParams(location.search);
|
|
165
|
-
params.delete('t');
|
|
166
|
-
location.href = location.pathname + (params.toString() ? `?${params}` : '');
|
|
167
|
-
location.reload();
|
|
168
|
-
});
|
|
169
|
-
document.body.append(form);
|
|
170
|
-
}
|