terminal-smart-cli 0.98.0 → 0.99.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.
@@ -0,0 +1,200 @@
1
+ 'use strict';
2
+ // Conexão WSS de SAÍDA do nó com o relay (PROTOCOLO.md §7). Nenhuma porta é aberta no nó.
3
+ //
4
+ // Ciclo: abre <relay>/ws → recebe relay.desafio → confere que é o relay esperado e responde
5
+ // relay.auth (papel "no") → relay.ok → "conectado". Cai = recuo exponencial com variação
6
+ // aleatória (1 s, 2 s, 4 s … até 60 s) e tenta de novo; zera o recuo ao autenticar.
7
+ // Usa o WebSocket nativo do Node 22 (TLS validado), sem dependência nova.
8
+
9
+ const { EventEmitter } = require('events');
10
+ const P = require('./proto/ts-proto.js');
11
+
12
+ function ehHostLocal(h) {
13
+ const x = String(h).toLowerCase();
14
+ return x === 'localhost' || x === '127.0.0.1' || x === '[::1]' || x.endsWith('.localhost');
15
+ }
16
+
17
+ /**
18
+ * Aceita "https://host", "wss://host" e, só para host local, "http://" / "ws://".
19
+ * Devolve { origem: "wss://host[:porta]", urlWs: origem + "/ws", local }.
20
+ */
21
+ function normalizarRelay(entrada) {
22
+ let u;
23
+ try { u = new URL(String(entrada)); } catch (_) { throw new Error('endereço do relay inválido'); }
24
+ const local = ehHostLocal(u.hostname);
25
+ let esquema;
26
+ if (u.protocol === 'https:' || u.protocol === 'wss:') esquema = 'wss:';
27
+ else if ((u.protocol === 'http:' || u.protocol === 'ws:') && local) esquema = 'ws:';
28
+ else throw new Error('o relay precisa ser https:// (http:// só para localhost em teste)');
29
+ if (u.username || u.password || u.search || u.hash || (u.pathname !== '/' && u.pathname !== '' && u.pathname !== '/ws')) {
30
+ throw new Error('o endereço do relay deve ser só a origem (sem caminho, consulta ou credenciais)');
31
+ }
32
+ const porta = u.port && !((esquema === 'wss:' && u.port === '443') || (esquema === 'ws:' && u.port === '80')) ? ':' + u.port : '';
33
+ const origem = esquema + '//' + u.hostname.toLowerCase() + porta;
34
+ return { origem, urlWs: origem + '/ws', local };
35
+ }
36
+
37
+ class ConexaoRelay extends EventEmitter {
38
+ /**
39
+ * opcoes: { relay (origem ws), urlWs, identidade, permitirLocal, recuoMinMs, recuoMaxMs,
40
+ * pingMs, silencioMs, authMs, log, WebSocket }
41
+ */
42
+ constructor(opcoes) {
43
+ super();
44
+ const o = opcoes || {};
45
+ this.origem = o.relay;
46
+ this.urlWs = o.urlWs;
47
+ this.identidade = o.identidade;
48
+ this.recuoMinMs = o.recuoMinMs || 1000;
49
+ this.recuoMaxMs = o.recuoMaxMs || 60000;
50
+ this.pingMs = o.pingMs || 25000;
51
+ this.silencioMs = o.silencioMs || 70000;
52
+ this.authMs = o.authMs || 15000;
53
+ this.log = o.log || (() => {});
54
+ this._WS = o.WebSocket || globalThis.WebSocket;
55
+ this.estado = 'parado';
56
+ this.tentativas = 0;
57
+ this.conexoes = 0; // autenticações bem-sucedidas (útil para status e testes)
58
+ this._ativo = false;
59
+ this._ger = 0;
60
+ this._ws = null;
61
+ this._timers = new Set();
62
+ this._ultimoRecebido = 0;
63
+ }
64
+
65
+ get conectado() { return this.estado === 'conectado'; }
66
+
67
+ iniciar() {
68
+ if (this._ativo) return;
69
+ if (typeof this._WS !== 'function') throw new Error('WebSocket indisponível neste Node (precisa do Node 22)');
70
+ this._ativo = true;
71
+ this._conectar();
72
+ }
73
+
74
+ parar() {
75
+ this._ativo = false;
76
+ this._derrubar(false);
77
+ this.estado = 'parado';
78
+ }
79
+
80
+ /** Envia um quadro de controle. Devolve false se não está conectado. */
81
+ enviar(obj) {
82
+ if (!this.conectado || !this._ws) return false;
83
+ const texto = JSON.stringify(obj);
84
+ try { P.verificarTamanhoQuadro(texto); } catch (_) { this.log('quadro grande demais descartado (E_TAMANHO)'); return false; }
85
+ try { this._ws.send(texto); return true; } catch (_) { return false; }
86
+ }
87
+
88
+ /** Força a queda (testes e "silêncio" do relay). Reconecta se ainda ativo. */
89
+ derrubar() { this._derrubar(true); }
90
+
91
+ _timer(fn, ms, repetir) {
92
+ const t = repetir ? setInterval(fn, ms) : setTimeout(() => { this._timers.delete(t); fn(); }, ms);
93
+ this._timers.add(t);
94
+ return t;
95
+ }
96
+ _limparTimers() {
97
+ for (const t of this._timers) { clearTimeout(t); clearInterval(t); }
98
+ this._timers.clear();
99
+ }
100
+
101
+ _conectar() {
102
+ if (!this._ativo) return;
103
+ const ger = ++this._ger;
104
+ this.estado = 'conectando';
105
+ let ws;
106
+ try { ws = new this._WS(this.urlWs); } catch (_) { this._caiu(ger); return; }
107
+ this._ws = ws;
108
+ // O prazo conta desde a tentativa, não desde o onopen: uma abertura que nunca termina (o
109
+ // relay caiu no meio do upgrade, rede presa) também é derrubada e tentada de novo.
110
+ this._timer(() => { if (ger === this._ger && !this.conectado) { this.log('relay não conectou/autenticou a tempo'); this._derrubar(true); } }, this.authMs);
111
+ ws.onopen = () => {
112
+ if (ger !== this._ger) return;
113
+ this.estado = 'autenticando';
114
+ this._ultimoRecebido = Date.now();
115
+ };
116
+ ws.onmessage = ev => {
117
+ if (ger !== this._ger) return;
118
+ this._ultimoRecebido = Date.now();
119
+ this._receber(ger, ev.data);
120
+ };
121
+ ws.onerror = () => {};
122
+ ws.onclose = () => { if (ger === this._ger) this._caiu(ger); };
123
+ }
124
+
125
+ async _receber(ger, texto) {
126
+ if (typeof texto !== 'string') return this._derrubar(true); // o relay só fala texto
127
+ let q;
128
+ try { q = P.lerQuadroRelay(texto, 'do-relay'); } catch (e) {
129
+ this.log('quadro do relay recusado (' + (e.codigo || 'E_FORMATO') + ')');
130
+ return;
131
+ }
132
+ if (this.estado === 'autenticando') {
133
+ if (q.t === 'relay.desafio') {
134
+ let resp;
135
+ try {
136
+ resp = await P.clienteResponderDesafio({ identidade: this.identidade, quadro: q, relayEsperado: this.origem });
137
+ } catch (e) {
138
+ this.log('desafio do relay recusado (' + (e.codigo || 'E_DESAFIO') + ')');
139
+ return this._derrubar(true);
140
+ }
141
+ if (ger !== this._ger) return;
142
+ try { this._ws.send(JSON.stringify(resp)); } catch (_) {}
143
+ return;
144
+ }
145
+ if (q.t === 'relay.ok') {
146
+ if (q.papel !== 'no' || q.id !== this.identidade.id) return this._derrubar(true);
147
+ this.estado = 'conectado';
148
+ this.tentativas = 0;
149
+ this.conexoes++;
150
+ this._timer(() => this._pingar(ger), this.pingMs, true);
151
+ this.emit('conectado');
152
+ return;
153
+ }
154
+ if (q.t === 'relay.erro') {
155
+ this.log('relay recusou a autenticação (' + q.codigo + ')');
156
+ return this._derrubar(true);
157
+ }
158
+ return;
159
+ }
160
+ if (this.estado !== 'conectado') return;
161
+ if (q.t === 'relay.pong') return;
162
+ this.emit('quadro', q);
163
+ }
164
+
165
+ _pingar(ger) {
166
+ if (ger !== this._ger || !this.conectado) return;
167
+ if (Date.now() - this._ultimoRecebido > this.silencioMs) {
168
+ this.log('relay em silêncio; reconectando');
169
+ return this._derrubar(true);
170
+ }
171
+ this.enviar({ t: 'relay.ping' });
172
+ }
173
+
174
+ _derrubar(reconectar) {
175
+ const ws = this._ws;
176
+ const estavaConectado = this.conectado;
177
+ this._ger++;
178
+ this._ws = null;
179
+ this._limparTimers();
180
+ if (ws) { try { ws.close(); } catch (_) {} }
181
+ this.estado = 'desconectado';
182
+ if (estavaConectado) this.emit('desconectado');
183
+ if (reconectar && this._ativo) this._agendar();
184
+ }
185
+
186
+ _caiu(ger) {
187
+ if (ger !== this._ger) return;
188
+ this._derrubar(true);
189
+ }
190
+
191
+ _agendar() {
192
+ const base = Math.min(this.recuoMaxMs, this.recuoMinMs * Math.pow(2, Math.min(this.tentativas, 20)));
193
+ const atraso = Math.round(base * (0.5 + Math.random() * 0.5));
194
+ this.tentativas++;
195
+ this.log('reconectando ao relay em ' + Math.max(1, Math.round(atraso / 1000)) + ' s');
196
+ this._timer(() => this._conectar(), atraso);
197
+ }
198
+ }
199
+
200
+ module.exports = { ConexaoRelay, normalizarRelay, ehHostLocal };
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+ // Provedor de terminais "telas em arquivo": o hub só LÊ instantâneos de um diretório
3
+ // (TS_HUB_TELAS_DIR). Quem grava é o `ts hub exportar-telas`, rodando como o usuário dos
4
+ // agentes (não como o hub): assim o hub não precisa de acesso ao tmux nem a nada dos agentes.
5
+ //
6
+ // <TS_HUB_TELAS_DIR>/
7
+ // indice.json { v:1, geradoEm, terminais:[{ terminalId, titulo, ativo, colunas, linhas }] }
8
+ // tela-<id>.json { v:1, terminalId, colunas, linhas, historico, geradoEm, tela }
9
+ //
10
+ // O conteúdo vem de outro usuário: tudo é conferido de novo (formato, tamanho, ID) e a tela
11
+ // passa pela sanitização e pela máscara no hub. Nada de seguir link: o diretório não pode ser
12
+ // link e os arquivos são abertos com O_NOFOLLOW (POSIX) e conferidos com fstat (regular e
13
+ // dentro do tamanho). Índice velho (exportador parado) = lista vazia.
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const P = require('./proto/ts-proto.js');
18
+
19
+ const INDICE = 'indice.json';
20
+ const RX_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; // vira nome de arquivo: sem ".", ":" ou "/"
21
+ const INDICE_MAX = 256 * 1024;
22
+ const TELA_ARQUIVO_MAX = 1024 * 1024;
23
+ const TERMINAIS_MAX = 100;
24
+ const VALIDADE_PADRAO_MS = 30000;
25
+
26
+ function erroCodigo(codigo) { return Object.assign(new Error(codigo), { codigo }); }
27
+
28
+ function arquivoTela(terminalId) { return 'tela-' + terminalId + '.json'; }
29
+
30
+ /** Lê um arquivo regular sem seguir link e sem passar de `max` bytes. Devolve texto ou null. */
31
+ function lerSeguro(arq, max) {
32
+ const c = fs.constants;
33
+ let fd;
34
+ try {
35
+ if (!c.O_NOFOLLOW) {
36
+ // Windows: sem O_NOFOLLOW; confere antes (link simbólico lá exige privilégio).
37
+ const l = fs.lstatSync(arq);
38
+ if (l.isSymbolicLink() || !l.isFile()) return null;
39
+ }
40
+ fd = fs.openSync(arq, c.O_RDONLY | (c.O_NOFOLLOW || 0) | (c.O_NONBLOCK || 0));
41
+ const st = fs.fstatSync(fd);
42
+ if (!st.isFile() || st.size > max) return null;
43
+ const buf = Buffer.alloc(st.size);
44
+ let lidos = 0;
45
+ while (lidos < st.size) {
46
+ const n = fs.readSync(fd, buf, lidos, st.size - lidos, lidos);
47
+ if (n <= 0) break;
48
+ lidos += n;
49
+ }
50
+ return buf.subarray(0, lidos).toString('utf8');
51
+ } catch (_) {
52
+ return null;
53
+ } finally {
54
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch (_) {} }
55
+ }
56
+ }
57
+
58
+ function inteiro(n, min, max) { return Number.isSafeInteger(n) && n >= min && n <= max; }
59
+
60
+ function limparTitulo(s) {
61
+ return String(s).replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g, ' ')
62
+ .replace(/\s+/g, ' ').trim().slice(0, 80);
63
+ }
64
+
65
+ /**
66
+ * opcoes: { dir, validadeMs (30 s), agora }
67
+ */
68
+ function criarProvedorTelas(opcoes) {
69
+ const o = opcoes || {};
70
+ const dir = path.resolve(String(o.dir));
71
+ const validadeMs = o.validadeMs || VALIDADE_PADRAO_MS;
72
+ const agora = o.agora || Date.now;
73
+
74
+ function dirOk() {
75
+ try { const st = fs.lstatSync(dir); return st.isDirectory() && !st.isSymbolicLink(); } catch (_) { return false; }
76
+ }
77
+
78
+ function lerIndice() {
79
+ if (!dirOk()) return [];
80
+ const texto = lerSeguro(path.join(dir, INDICE), INDICE_MAX);
81
+ if (texto === null) return [];
82
+ let j;
83
+ try { j = JSON.parse(texto); } catch (_) { return []; }
84
+ if (!j || j.v !== 1 || !Number.isSafeInteger(j.geradoEm) || !Array.isArray(j.terminais)) return [];
85
+ const idade = agora() - j.geradoEm;
86
+ if (idade > validadeMs || idade < -validadeMs) return []; // exportador parado (ou relógio absurdo)
87
+ const out = [];
88
+ const vistos = new Set();
89
+ for (const t of j.terminais) {
90
+ if (out.length >= TERMINAIS_MAX) break;
91
+ if (!t || typeof t.terminalId !== 'string' || !RX_ID.test(t.terminalId) || vistos.has(t.terminalId)) continue;
92
+ if (!inteiro(t.colunas, 1, 1000) || !inteiro(t.linhas, 1, 1000)) continue;
93
+ vistos.add(t.terminalId);
94
+ out.push({
95
+ terminalId: t.terminalId,
96
+ titulo: limparTitulo(typeof t.titulo === 'string' ? t.titulo : t.terminalId) || t.terminalId,
97
+ ativo: t.ativo === true,
98
+ colunas: t.colunas,
99
+ linhas: t.linhas,
100
+ });
101
+ }
102
+ return out;
103
+ }
104
+
105
+ return {
106
+ nome: 'arquivo',
107
+ dir,
108
+ async listar() { return lerIndice(); },
109
+ /** Tela visível + até `historico` linhas anteriores (o instantâneo tem no máximo 200). */
110
+ async capturar(terminalId, historico) {
111
+ if (typeof terminalId !== 'string' || !RX_ID.test(terminalId)) throw erroCodigo('E_PARAMS');
112
+ const h = historico === undefined ? 0 : historico;
113
+ if (!inteiro(h, 0, P.LIMITES.HISTORICO_MAX)) throw erroCodigo('E_PARAMS');
114
+ if (!dirOk()) throw erroCodigo('E_TERMINAL_INEXISTENTE');
115
+ const texto = lerSeguro(path.join(dir, arquivoTela(terminalId)), TELA_ARQUIVO_MAX);
116
+ if (texto === null) throw erroCodigo('E_TERMINAL_INEXISTENTE');
117
+ let j;
118
+ try { j = JSON.parse(texto); } catch (_) { throw erroCodigo('E_TERMINAL_INEXISTENTE'); }
119
+ if (!j || j.v !== 1 || j.terminalId !== terminalId || typeof j.tela !== 'string' || !inteiro(j.linhas, 1, 1000)) {
120
+ throw erroCodigo('E_TERMINAL_INEXISTENTE');
121
+ }
122
+ const linhas = j.tela.split('\n');
123
+ const final = linhas.length && linhas[linhas.length - 1] === '' ? 1 : 0;
124
+ const manter = j.linhas + h + final;
125
+ return linhas.length > manter ? linhas.slice(linhas.length - manter).join('\n') : j.tela;
126
+ },
127
+ };
128
+ }
129
+
130
+ module.exports = { criarProvedorTelas, lerSeguro, arquivoTela, INDICE, RX_ID, TELA_ARQUIVO_MAX, INDICE_MAX };
package/hub/tmux.js ADDED
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+ // Leitura do servidor tmux dedicado (`tmux -L ts`), só leitura.
3
+ //
4
+ // Uso normal: `ts hub exportar-telas` (rodando como o usuário dos agentes) usa estas funções
5
+ // para gravar instantâneos que o hub lê de um diretório (hub/telas-arquivo.js). O hub em si
6
+ // NÃO fala com o tmux. O provedor direto (criarProvedorTmux no hub) é só para desenvolvimento
7
+ // e testes, ligado com TS_HUB_TMUX_DIRETO=1.
8
+ //
9
+ // - Sem shell: tudo por execFile('tmux', [...]). O executor é injetável (testes usam um tmux
10
+ // falso).
11
+ // - terminalId = "p<N>" para o painel "%N" do tmux. O ID é validado duas vezes: pelo
12
+ // protocolo (validarParametros) e aqui (/^p\d{1,9}$/), e só vira alvo `-t %N` depois disso.
13
+ // - No Windows, ou sem tmux / sem servidor `-L ts` no ar, a lista é vazia.
14
+
15
+ const { execFile } = require('child_process');
16
+ const P = require('./proto/ts-proto.js');
17
+
18
+ const SOCKET_TMUX = 'ts';
19
+ const RX_TERMINAL_TMUX = /^p\d{1,9}$/;
20
+ const TITULO_MAX = 80;
21
+ // A tela vai inteira num quadro de sessão: o limite real é o texto claro do quadro (32 KiB,
22
+ // PROTOCOLO §5), conferido em hub.js; isto é só o teto bruto antes da conta do JSON.
23
+ const TELA_MAX_BYTES = 32 * 1024;
24
+
25
+ // Campos fixos primeiro; os de texto livre (sessão e janela) no fim, para tabulação dentro do
26
+ // nome não bagunçar os numéricos.
27
+ const FORMATO = [
28
+ '#{pane_id}', '#{window_index}', '#{pane_index}', '#{window_panes}',
29
+ '#{pane_active}', '#{window_active}', '#{pane_width}', '#{pane_height}',
30
+ '#{session_name}', '#{window_name}',
31
+ ].join('\t');
32
+
33
+ function executorPadrao(arquivo, args) {
34
+ return new Promise((resolve, reject) => {
35
+ execFile(arquivo, args, { timeout: 5000, maxBuffer: 8 * 1024 * 1024, windowsHide: true, encoding: 'utf8' },
36
+ (err, stdout, stderr) => {
37
+ if (err) { err.stderr = String(stderr || ''); return reject(err); }
38
+ resolve(String(stdout));
39
+ });
40
+ });
41
+ }
42
+
43
+ function limparTexto(s, max) {
44
+ return String(s)
45
+ .replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g, ' ')
46
+ .replace(/\s+/g, ' ')
47
+ .trim()
48
+ .slice(0, max);
49
+ }
50
+
51
+ function analisarLista(stdout) {
52
+ const terminais = [];
53
+ const vistos = new Set();
54
+ for (const linha of String(stdout).split('\n')) {
55
+ if (!linha) continue;
56
+ const c = linha.split('\t');
57
+ if (c.length < 10) continue;
58
+ const m = /^%(\d{1,9})$/.exec(c[0]);
59
+ if (!m) continue;
60
+ const terminalId = 'p' + m[1];
61
+ if (vistos.has(terminalId)) continue;
62
+ const nums = c.slice(1, 8).map(x => (/^\d{1,6}$/.test(x) ? Number(x) : NaN));
63
+ if (nums.some(n => !Number.isSafeInteger(n))) continue;
64
+ const [janela, painel, paineis, painelAtivo, janelaAtiva, colunas, linhas] = nums;
65
+ const sessao = c[8];
66
+ const nomeJanela = c.slice(9).join(' ');
67
+ let titulo = limparTexto(sessao, 40) + ':' + janela + (nomeJanela ? ' ' + limparTexto(nomeJanela, 40) : '');
68
+ if (paineis > 1) titulo += ' [' + painel + ']';
69
+ vistos.add(terminalId);
70
+ terminais.push({
71
+ terminalId,
72
+ titulo: titulo.slice(0, TITULO_MAX),
73
+ ativo: painelAtivo === 1 && janelaAtiva === 1,
74
+ colunas,
75
+ linhas,
76
+ // Nomes crus (só para a lista de janelas permitidas do exportar-telas; não vão ao celular).
77
+ sessao,
78
+ janela: nomeJanela,
79
+ });
80
+ }
81
+ return terminais;
82
+ }
83
+
84
+ /**
85
+ * Mantém só escapes SGR (cor/estilo: ESC [ ... m). Tira os demais escapes (movimento de
86
+ * cursor, OSC, títulos, hiperlinks) e caracteres de controle, exceto \n e \t.
87
+ */
88
+ function sanitizarTela(texto) {
89
+ const partes = String(texto).replace(/\r\n?/g, '\n').split(/(\x1b\[[0-9;:]{0,64}m)/);
90
+ let out = '';
91
+ for (let i = 0; i < partes.length; i++) {
92
+ if (i % 2 === 1) { out += partes[i]; continue; }
93
+ out += partes[i]
94
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, '')
95
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
96
+ .replace(/\x1b[ -/]*[0-~]/g, '') // ESC ( B, ESC =, ESC 7 … (charset, modos)
97
+ .replace(/\x1b[\s\S]?/g, '')
98
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '');
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /**
104
+ * Corta o histórico mais antigo (linhas do começo) até `cabe(tela)` ser verdade.
105
+ * Se nem a última linha couber, guarda o fim dela.
106
+ */
107
+ function cortarTela(tela, cabe) {
108
+ if (cabe(tela)) return tela;
109
+ const linhas = tela.split('\n');
110
+ // Busca binária: maior quantidade de linhas finais que cabe.
111
+ let lo = 0, hi = linhas.length;
112
+ while (lo < hi) {
113
+ const meio = Math.ceil((lo + hi) / 2);
114
+ if (cabe(linhas.slice(linhas.length - meio).join('\n'))) lo = meio; else hi = meio - 1;
115
+ }
116
+ if (lo > 0) return linhas.slice(linhas.length - lo).join('\n');
117
+ let ultima = linhas[linhas.length - 1];
118
+ let a = 0, b = ultima.length;
119
+ while (a < b) {
120
+ const meio = Math.ceil((a + b) / 2);
121
+ if (cabe(ultima.slice(ultima.length - meio))) a = meio; else b = meio - 1;
122
+ }
123
+ return ultima.slice(ultima.length - a);
124
+ }
125
+
126
+ function ehSemServidor(err) {
127
+ if (!err) return false;
128
+ if (err.code === 'ENOENT') return true;
129
+ const s = String(err.stderr || err.message || '');
130
+ return /no server running|error connecting|no such file|server exited|failed to connect/i.test(s);
131
+ }
132
+
133
+ /**
134
+ * opcoes.executar(arquivo, args) → Promise<stdout> (padrão: execFile, sem shell)
135
+ * opcoes.plataforma (padrão: process.platform)
136
+ */
137
+ function criarProvedorTmux(opcoes) {
138
+ const o = opcoes || {};
139
+ const plataforma = o.plataforma || process.platform;
140
+ if (!o.executar && plataforma === 'win32') return provedorVazio();
141
+ const executar = o.executar || executorPadrao;
142
+ const tmux = args => executar('tmux', ['-u', '-L', SOCKET_TMUX].concat(args));
143
+
144
+ return {
145
+ nome: 'tmux',
146
+ async listar() {
147
+ let out;
148
+ try { out = await tmux(['list-panes', '-a', '-F', FORMATO]); } catch (err) {
149
+ if (ehSemServidor(err)) return [];
150
+ return [];
151
+ }
152
+ return analisarLista(out);
153
+ },
154
+ async capturar(terminalId, historico) {
155
+ if (typeof terminalId !== 'string' || !RX_TERMINAL_TMUX.test(terminalId)) {
156
+ throw Object.assign(new Error('terminalId inválido'), { codigo: 'E_PARAMS' });
157
+ }
158
+ const h = historico === undefined ? 0 : historico;
159
+ if (!Number.isSafeInteger(h) || h < 0 || h > P.LIMITES.HISTORICO_MAX) throw Object.assign(new Error('histórico inválido'), { codigo: 'E_PARAMS' });
160
+ const alvo = '%' + terminalId.slice(1);
161
+ try {
162
+ return await tmux(['capture-pane', '-p', '-e', '-J', '-t', alvo, '-S', '-' + h]);
163
+ } catch (_) {
164
+ throw Object.assign(new Error('terminal indisponível'), { codigo: 'E_TERMINAL_INEXISTENTE' });
165
+ }
166
+ },
167
+ };
168
+ }
169
+
170
+ function provedorVazio() {
171
+ return {
172
+ nome: 'vazio',
173
+ async listar() { return []; },
174
+ async capturar() { throw Object.assign(new Error('sem terminais'), { codigo: 'E_TERMINAL_INEXISTENTE' }); },
175
+ };
176
+ }
177
+
178
+ module.exports = {
179
+ criarProvedorTmux,
180
+ executorPadrao,
181
+ SOCKET_TMUX,
182
+ provedorVazio,
183
+ analisarLista,
184
+ sanitizarTela,
185
+ cortarTela,
186
+ FORMATO,
187
+ RX_TERMINAL_TMUX,
188
+ TELA_MAX_BYTES,
189
+ };
@@ -0,0 +1,109 @@
1
+ # Exemplos para a VPS dos agentes (NÃO aplicados)
2
+
3
+ Arquivos de referência para a implantação (etapa 7, com aprovação). Nada aqui foi instalado.
4
+ Caminhos e nomes de usuário (`tshub`, `agentes`) são os do plano; ajuste antes de usar.
5
+
6
+ ## 1. "Parar tudo" com confirmação real
7
+
8
+ O hub roda como `tshub`, sem privilégio. Ele **não** para nada sozinho:
9
+
10
+ 1. o celular manda `hub.stopAll {confirmar:true}`;
11
+ 2. o hub cria `pedidos/parar-<carimbo ms>-<16 hex>.pedido` em `TS_HUB_PARAR_DIR`, com
12
+ `O_CREAT|O_EXCL|O_NOFOLLOW` (não sobrescreve, não segue link) e espera até 10 s;
13
+ 3. a unidade `ts-hub-parar.path` (root) vê o diretório não vazio e dispara
14
+ `ts-hub-parar.service`, que roda `ts-hub-parar-consumir`:
15
+ - **não lê** o conteúdo de nenhum pedido; só valida o nome
16
+ (`^parar-[0-9]{13}-[0-9a-f]{16}\.pedido$`, arquivo regular, não link);
17
+ - renomeia cada entrada de `pedidos/` para `consumidos/` (as inválidas como
18
+ `invalido-...`): o diretório fica vazio e o `.path` não dispara de novo (sem laço);
19
+ - roda `/usr/local/sbin/ts-parar-tudo "celular"` **uma vez** por lote;
20
+ - grava `confirmacoes/<base>.ok` (ou `.falhou`), atômico (`mktemp` + `mv`), 0644;
21
+ 4. o hub vê a confirmação **do dono certo** (uid 0, arquivo regular, não link) e só então
22
+ responde `{parado:true}`. Sem confirmação em 10 s (ou com `.falhou`):
23
+ `E_PARAR_SEM_CONFIRMACAO`, e o celular mostra "não confirmado".
24
+
25
+ `hub.status`/`hub.estado` dizem `parado:true` só se existir uma confirmação `.ok` válida — o
26
+ pedido sozinho não conta. **Religar** = o root apagar as confirmações (depois de religar os
27
+ agentes):
28
+
29
+ ```sh
30
+ rm -f /var/lib/ts-hub-parar/confirmacoes/*.ok
31
+ ```
32
+
33
+ Nó sem `TS_HUB_PARAR_DIR` (ou com os diretórios fora do lugar, com dono ou modo errados)
34
+ anuncia `pararDisponivel:false` em `hub.status` e a PWA desabilita o botão para ele; um
35
+ `hub.stopAll` assim recebe `E_PARAR_INDISPONIVEL`. É o caso do PC na entrega 1.
36
+
37
+ ### Arquivos
38
+
39
+ | Arquivo | Destino | Dono/modo |
40
+ |---|---|---|
41
+ | `ts-hub-parar.tmpfiles.conf` | `/etc/tmpfiles.d/ts-hub-parar.conf` | root, 0644 |
42
+ | `ts-hub-parar.path` | `/etc/systemd/system/` | root, 0644 |
43
+ | `ts-hub-parar.service` | `/etc/systemd/system/` | root, 0644 |
44
+ | `ts-hub-parar-consumir` | `/usr/local/sbin/` | root, 0755 |
45
+
46
+ Diretórios (criados pelo tmpfiles):
47
+
48
+ | Diretório | Dono | Modo | Quem escreve |
49
+ |---|---|---|---|
50
+ | `/var/lib/ts-hub-parar` | root | 0755 | ninguém |
51
+ | `pedidos/` | tshub | 0700 | só o hub |
52
+ | `confirmacoes/` | root | 0755 | só o root (o hub lê) |
53
+ | `consumidos/` | root | 0700 | só o root (limpo após 30 dias) |
54
+
55
+ O hub confere isso a cada uso: `pedidos/` do próprio usuário e `confirmacoes/` de root, os
56
+ dois sem escrita para grupo/outros e sem link. Assim nem um agente (outro usuário) nem o
57
+ próprio processo do hub conseguem forjar uma confirmação.
58
+
59
+ ### Instalação (quando aprovada)
60
+
61
+ ```sh
62
+ install -o root -g root -m 0644 ts-hub-parar.tmpfiles.conf /etc/tmpfiles.d/ts-hub-parar.conf
63
+ systemd-tmpfiles --create /etc/tmpfiles.d/ts-hub-parar.conf
64
+ install -o root -g root -m 0755 ts-hub-parar-consumir /usr/local/sbin/ts-hub-parar-consumir
65
+ install -o root -g root -m 0644 ts-hub-parar.path ts-hub-parar.service /etc/systemd/system/
66
+ systemctl daemon-reload
67
+ systemctl enable --now ts-hub-parar.path
68
+ # no ambiente do serviço do hub (usuário tshub):
69
+ # TS_HUB_PARAR_DIR=/var/lib/ts-hub-parar
70
+ ```
71
+
72
+ Teste manual (sem o celular): como `tshub`, `touch /var/lib/ts-hub-parar/pedidos/parar-0000000000000-0000000000000000.pedido`
73
+ e confira `confirmacoes/` e `journalctl -u ts-hub-parar.service`.
74
+
75
+ ## 2. Telas dos terminais sem privilégio no hub
76
+
77
+ O hub não fala com o tmux. Quem lê o tmux é `ts hub exportar-telas`, rodando como o
78
+ **usuário dos agentes** (que já tem o `tmux -L ts`), e grava instantâneos em
79
+ `TS_HUB_TELAS_DIR`; o hub só lê esse diretório (grupo `ts-telas`, modo 0750/0640).
80
+
81
+ - Só janelas da lista `TS_HUB_TELAS_PERMITIDAS` (`sessao:janela` ou `sessao:*`); sem lista,
82
+ nada é exportado.
83
+ - Histórico de no máximo 200 linhas; só cores (SGR) sobrevivem; segredos conhecidos são
84
+ mascarados (`sk-…`, `sk-ant-…`, `ghp_…`/`gho_…`/`github_pat_…`, `AKIA…`, `xox…`, JWT,
85
+ `-----BEGIN … PRIVATE KEY-----`, `usuario:senha@` em URL). A máscara é rede de segurança,
86
+ não garantia: escolha as janelas exportadas com cuidado.
87
+ - Gravação atômica; índice velho (exportador parado há mais de 30 s) = lista vazia no hub.
88
+
89
+ Exemplo: `ts-hub-exportar-telas.service` (ajuste `User=`, a lista e o caminho do `ts`).
90
+ Diretório: `install -d -o tsagentes -g ts-telas -m 2750 /var/lib/ts-hub-telas` e
91
+ `usermod -aG ts-telas tshub`. **Não** ponha o `tsagentes` no grupo `ts-telas`: o `Group=` do
92
+ serviço já vale para o processo, e o `ts-vigia` aciona o Parar tudo se a conta dos agentes
93
+ ganhar grupo. O exportador roda sem `PrivateTmp` (o socket do tmux fica em `/tmp/tmux-<uid>/`). No serviço
94
+ do hub: `TS_HUB_TELAS_DIR=/var/lib/ts-hub-telas`.
95
+
96
+ ## 2b. O tmux dos agentes
97
+
98
+ `ts-tmux.service` (unidade de usuário de root em `/etc/systemd/user/`, só para o `tsagentes`) com
99
+ `tmux-agentes.conf` (`default-shell /bin/sh`: a conta é `nologin`). Crie as janelas sempre com
100
+ `-c <pasta acessível>` (ex.: `%h`): o tmux herda a pasta do cliente, e `/home` é inacessível para os
101
+ agentes — o painel morreria com status 1.
102
+
103
+ ## 3. O hub
104
+
105
+ `ts-hub.service` (exemplo): usuário `tshub`, instalação mínima em `/opt/ts-hub` (só `hub/`,
106
+ `package.json` e `node_modules/qrcode-terminal`; o hub usa o WebSocket nativo do Node 22),
107
+ entrada `hub/principal.js`. Parear pela própria VPS: `sudo -u tshub env TS_HUB_DIR=/var/lib/tshub/hub node /opt/ts-hub/hub/principal.js parear` (a confirmação é digitada ali mesmo).
108
+
109
+ O provedor que lê o tmux direto do hub (`TS_HUB_TMUX_DIRETO=1`) é só para desenvolvimento.
@@ -0,0 +1,5 @@
1
+ # /usr/local/lib/ts/tmux-agentes.conf — tmux dos agentes (servidor -L ts, usuário tsagentes).
2
+ # A conta não tem shell de login (nologin, conferido pelo ts-vigia); sem esta linha toda
3
+ # janela morre na hora com status 1. As janelas rodam com /bin/sh.
4
+ set -g default-shell /bin/sh
5
+ set -g history-limit 5000
@@ -0,0 +1,30 @@
1
+ # EXEMPLO — não aplicado. Copiar para /etc/systemd/system/ts-hub-exportar-telas.service.
2
+ # Roda como o USUÁRIO DOS AGENTES (tsagentes) (quem já tem o tmux -L ts), nunca como tshub nem root.
3
+ # O hub (tshub) só lê /var/lib/ts-hub-telas pelo grupo ts-telas. Ver README.md.
4
+ # Group= vale só para este processo: o tsagentes NÃO entra no grupo ts-telas (o ts-vigia
5
+ # aciona o Parar tudo se a conta dos agentes ganhar grupo).
6
+ [Unit]
7
+ Description=Terminal Smart: exporta as telas do tmux dos agentes para o hub (só leitura)
8
+
9
+ [Service]
10
+ User=tsagentes
11
+ Group=ts-telas
12
+ UMask=0027
13
+ Environment=TS_HUB_TELAS_DIR=/var/lib/ts-hub-telas
14
+ # Só estas janelas saem da máquina (sessao:janela ou sessao:*). Ajustar à VPS.
15
+ Environment=TS_HUB_TELAS_PERMITIDAS=agentes:claude,agentes:codex
16
+ Environment=TS_HUB_TELAS_HISTORICO=200
17
+ ExecStart=/usr/bin/node /opt/ts-hub/hub/principal.js exportar-telas
18
+ Restart=on-failure
19
+ RestartSec=5
20
+ NoNewPrivileges=yes
21
+ # Sem PrivateTmp: o socket do tmux dos agentes fica em /tmp/tmux-<uid>/ts.
22
+ PrivateTmp=no
23
+ # Não precisa de rede (o socket do tmux é de arquivo e funciona sem rede).
24
+ PrivateNetwork=yes
25
+ IPAddressDeny=any
26
+ ProtectSystem=strict
27
+ ReadWritePaths=/var/lib/ts-hub-telas
28
+
29
+ [Install]
30
+ WantedBy=multi-user.target