kafox-cli 0.10.0 → 0.10.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/package.json +1 -1
- package/src/compact.js +40 -0
- package/src/mobile/auth.js +122 -0
- package/src/mobile/bridge.js +12 -1
- package/src/mobile/captcha.js +96 -0
- package/src/mobile/history.js +71 -0
- package/src/mobile/index.js +18 -0
- package/src/mobile/ratelimit.js +79 -0
- package/src/mobile/server.js +105 -3
- package/src/mobile/tui-bridge.js +1 -0
- package/src/mobile/view.js +223 -14
- package/src/tui.js +38 -8
package/package.json
CHANGED
package/src/compact.js
CHANGED
|
@@ -30,6 +30,10 @@ const { chat } = require("./gateway");
|
|
|
30
30
|
const DEFAULT_THRESHOLD_TOKENS = 60_000;
|
|
31
31
|
// Quantas mensagens recentes ficam intactas depois do resumo.
|
|
32
32
|
const DEFAULT_KEEP_MESSAGES = 8;
|
|
33
|
+
|
|
34
|
+
// Quantos turnos de usuario manter no historico enviado ao modelo. Casa com
|
|
35
|
+
// "~20 mensagens" (10 do usuario + 10 da IA). Mantem o chat leve e o custo baixo.
|
|
36
|
+
const DEFAULT_KEEP_TURNS = 10;
|
|
33
37
|
// Teto do resumo: o texto gerado vira contexto permanente, entao nao pode crescer.
|
|
34
38
|
const MAX_SUMMARY_CHARS = 4_000;
|
|
35
39
|
|
|
@@ -73,6 +77,40 @@ function safeCutIndex(messages, wantedIndex) {
|
|
|
73
77
|
return Math.max(1, index);
|
|
74
78
|
}
|
|
75
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Mantem so os ultimos `maxUserTurns` turnos do usuario no historico enviado
|
|
82
|
+
* ao modelo (mais o system). Cada turno arrasta junto a resposta do assistant
|
|
83
|
+
* e os pares de tool, entao o resultado gira em torno de `2 * maxUserTurns`
|
|
84
|
+
* mensagens — ex.: 10 turnos ~ 10 do usuario + 10 da IA.
|
|
85
|
+
*
|
|
86
|
+
* O corte e sempre seguro: usa safeCutIndex pra nao deixar tool orfao nem
|
|
87
|
+
* assistant(tool_calls) sem os resultados. Devolve sempre um array novo.
|
|
88
|
+
*/
|
|
89
|
+
function trimHistory(messages, maxUserTurns = DEFAULT_KEEP_TURNS) {
|
|
90
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
91
|
+
if (maxUserTurns <= 0 || list.length === 0) return list;
|
|
92
|
+
// Quantos turnos de usuario existem e onde comecam os ultimos N.
|
|
93
|
+
let seen = 0;
|
|
94
|
+
let wanted = 0;
|
|
95
|
+
for (let i = list.length - 1; i >= 0; i -= 1) {
|
|
96
|
+
if (list[i]?.role === "user") {
|
|
97
|
+
seen += 1;
|
|
98
|
+
if (seen >= maxUserTurns) {
|
|
99
|
+
wanted = i;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (seen < maxUserTurns) return list.slice(); // cabem todos: nada a cortar
|
|
105
|
+
const cut = safeCutIndex(list, wanted);
|
|
106
|
+
const trimmed = list.slice(cut);
|
|
107
|
+
// Recoloca o system (que vive no indice 0) a frente, se existia.
|
|
108
|
+
if (list[0]?.role === "system" && trimmed[0]?.role !== "system") {
|
|
109
|
+
trimmed.unshift(list[0]);
|
|
110
|
+
}
|
|
111
|
+
return trimmed;
|
|
112
|
+
}
|
|
113
|
+
|
|
76
114
|
/** Texto corrido do historico, para alimentar o resumo. */
|
|
77
115
|
function transcript(messages, limitPerMessage = 1_500) {
|
|
78
116
|
return messages
|
|
@@ -334,6 +372,7 @@ module.exports = {
|
|
|
334
372
|
compactIfNeeded,
|
|
335
373
|
estimateTokens,
|
|
336
374
|
safeCutIndex,
|
|
375
|
+
trimHistory,
|
|
337
376
|
summaryPrompt,
|
|
338
377
|
buildCompactionNote,
|
|
339
378
|
scrubSecrets,
|
|
@@ -342,5 +381,6 @@ module.exports = {
|
|
|
342
381
|
SUMMARY_SECTIONS,
|
|
343
382
|
DEFAULT_THRESHOLD_TOKENS,
|
|
344
383
|
DEFAULT_KEEP_MESSAGES,
|
|
384
|
+
DEFAULT_KEEP_TURNS,
|
|
345
385
|
MAX_SUMMARY_CHARS,
|
|
346
386
|
};
|
package/src/mobile/auth.js
CHANGED
|
@@ -115,6 +115,128 @@ class PairingManager {
|
|
|
115
115
|
}
|
|
116
116
|
this.currentCode = null;
|
|
117
117
|
this.currentCodeHash = null;
|
|
118
|
+
// Pedidos de pareamento aguardando aprovacao do dono do terminal.
|
|
119
|
+
// requestId -> { createdAt, expiresAt, code, device, sessionTitle, status,
|
|
120
|
+
// token?, tokenExpiresAt? }
|
|
121
|
+
this.requests = new Map();
|
|
122
|
+
// Listener chamado quando chega um pedido novo (a TUI usa pra abrir modal).
|
|
123
|
+
this.onRequest = null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* PASSO 1 do novo fluxo: o celular pede entrada. NAO devolve token ainda —
|
|
128
|
+
* cria um pedido pendente e avisa o terminal (this.onRequest). O dono
|
|
129
|
+
* aprova/nega; so entao o token e emitido (ver resolveRequest/claim).
|
|
130
|
+
* @returns {{requestId:string, expiresAt:number}|null}
|
|
131
|
+
*/
|
|
132
|
+
requestPair(code, device = {}, meta = {}) {
|
|
133
|
+
if (!this.validateCode(code)) return null;
|
|
134
|
+
// Consome o codigo JA na criacao do pedido: garante uso unico (dois
|
|
135
|
+
// celulares nao podem abrir pedidos concorrentes com o mesmo codigo).
|
|
136
|
+
const codeHash = sha256(code);
|
|
137
|
+
this.pending.delete(codeHash);
|
|
138
|
+
if (this.currentCodeHash === codeHash) {
|
|
139
|
+
this.currentCode = null;
|
|
140
|
+
this.currentCodeHash = null;
|
|
141
|
+
}
|
|
142
|
+
const requestId = crypto.randomBytes(16).toString("hex");
|
|
143
|
+
const expiresAt = Date.now() + PAIR_TTL_MS;
|
|
144
|
+
const req = {
|
|
145
|
+
createdAt: Date.now(),
|
|
146
|
+
expiresAt,
|
|
147
|
+
code,
|
|
148
|
+
device,
|
|
149
|
+
sessionTitle: String(meta.sessionTitle || "").slice(0, 120),
|
|
150
|
+
sessionId: String(meta.sessionId || "").slice(0, 40),
|
|
151
|
+
status: "pending", // pending | approved | denied
|
|
152
|
+
token: null,
|
|
153
|
+
tokenExpiresAt: 0,
|
|
154
|
+
};
|
|
155
|
+
this.requests.set(requestId, req);
|
|
156
|
+
// Avisa o terminal (best-effort: se nao houver listener, so fica pendente).
|
|
157
|
+
try {
|
|
158
|
+
this.onRequest?.({ requestId, device, sessionTitle: req.sessionTitle, expiresAt });
|
|
159
|
+
} catch {
|
|
160
|
+
/* nao deixa o pedido quebrar por erro do listener */
|
|
161
|
+
}
|
|
162
|
+
this.audit("pair-request", { requestId: requestId.slice(0, 12), device });
|
|
163
|
+
return { requestId, expiresAt };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** PASSO 2 (terminal): aprova ou nega um pedido. Ao aprovar, emite o token. */
|
|
167
|
+
resolveRequest(requestId, approved, device = null) {
|
|
168
|
+
const req = this.requests.get(requestId);
|
|
169
|
+
if (!req) return false;
|
|
170
|
+
if (Date.now() > req.expiresAt) {
|
|
171
|
+
this.requests.delete(requestId);
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
if (req.status !== "pending") return false;
|
|
175
|
+
if (!approved) {
|
|
176
|
+
req.status = "denied";
|
|
177
|
+
this.audit("pair-denied", { requestId: requestId.slice(0, 12) });
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
// Aprovado: consome o codigo (single-use) e emite o token de sessao.
|
|
181
|
+
const codeHash = sha256(req.code);
|
|
182
|
+
this.pending.delete(codeHash);
|
|
183
|
+
if (this.currentCodeHash === codeHash) {
|
|
184
|
+
this.currentCode = null;
|
|
185
|
+
this.currentCodeHash = null;
|
|
186
|
+
}
|
|
187
|
+
const token = crypto.randomBytes(32).toString("hex");
|
|
188
|
+
const tokenExpiresAt = Date.now() + TOKEN_TTL_MS;
|
|
189
|
+
this.tokens.set(sha256(token), {
|
|
190
|
+
createdAt: Date.now(),
|
|
191
|
+
expiresAt: tokenExpiresAt,
|
|
192
|
+
code: req.code,
|
|
193
|
+
device: device || req.device,
|
|
194
|
+
});
|
|
195
|
+
this.persistTokens();
|
|
196
|
+
req.status = "approved";
|
|
197
|
+
req.token = token;
|
|
198
|
+
req.tokenExpiresAt = tokenExpiresAt;
|
|
199
|
+
this.audit("pair-approved", { requestId: requestId.slice(0, 12), device: req.device });
|
|
200
|
+
// Notifica o terminal: um pareamento CONCLUIU agora (some com o QR).
|
|
201
|
+
try {
|
|
202
|
+
this.onPaired?.({ requestId, device: device || req.device, sessionTitle: req.sessionTitle });
|
|
203
|
+
} catch {
|
|
204
|
+
/* listener do terminal nunca derruba o pareamento */
|
|
205
|
+
}
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* PASSO 3 (celular): consulta o status do pedido. Quando aprovado, devolve o
|
|
211
|
+
* token UMA vez e marca o pedido como consumido (nao vaza token em retries).
|
|
212
|
+
* @returns {{status:string, token?:string, expiresAt?:number}}
|
|
213
|
+
*/
|
|
214
|
+
pollRequest(requestId) {
|
|
215
|
+
const req = this.requests.get(requestId);
|
|
216
|
+
if (!req) return { status: "unknown" };
|
|
217
|
+
if (req.status === "pending" && Date.now() > req.expiresAt) {
|
|
218
|
+
this.requests.delete(requestId);
|
|
219
|
+
return { status: "expired" };
|
|
220
|
+
}
|
|
221
|
+
if (req.status === "approved") {
|
|
222
|
+
const token = req.token;
|
|
223
|
+
const expiresAt = req.tokenExpiresAt;
|
|
224
|
+
// single-shot: o token so sai do servidor uma vez.
|
|
225
|
+
this.requests.delete(requestId);
|
|
226
|
+
return { status: "approved", token, expiresAt };
|
|
227
|
+
}
|
|
228
|
+
return { status: req.status };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Pedidos pendentes (para reabrir o modal se a TUI renderizar de novo). */
|
|
232
|
+
pendingRequests() {
|
|
233
|
+
const out = [];
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
for (const [id, req] of [...this.requests]) {
|
|
236
|
+
if (req.status === "pending" && now <= req.expiresAt) out.push({ requestId: id, ...req });
|
|
237
|
+
else if (now > req.expiresAt) this.requests.delete(id);
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
118
240
|
}
|
|
119
241
|
|
|
120
242
|
/**
|
package/src/mobile/bridge.js
CHANGED
|
@@ -20,6 +20,7 @@ const { EventEmitter } = require("events");
|
|
|
20
20
|
const { runAgent } = require("../agent");
|
|
21
21
|
const { estimateTokens } = require("../compact");
|
|
22
22
|
const sessions = require("../sessions");
|
|
23
|
+
const { trimHistory } = require("./history");
|
|
23
24
|
const { EventLog } = require("./protocol");
|
|
24
25
|
|
|
25
26
|
const SAVE_DEBOUNCE_MS = 400;
|
|
@@ -39,6 +40,7 @@ class SessionBridge extends EventEmitter {
|
|
|
39
40
|
super();
|
|
40
41
|
this.cwd = cwd;
|
|
41
42
|
this.sessionId = sessionId || sessions.newId();
|
|
43
|
+
this.title = null; // preenchido no snapshot a partir da 1a mensagem do usuario
|
|
42
44
|
this.sessionStartedAt = sessionStartedAt || new Date().toISOString();
|
|
43
45
|
this.messages = Array.isArray(messages) ? messages : [];
|
|
44
46
|
this.context = Array.isArray(context) ? context : [];
|
|
@@ -58,14 +60,23 @@ class SessionBridge extends EventEmitter {
|
|
|
58
60
|
|
|
59
61
|
/** Snapshot inicial para quem acabou de conectar (historico + estado). */
|
|
60
62
|
snapshot() {
|
|
63
|
+
// O celular nao sobe o chat inteiro: manda so as ultimas ~20 mensagens
|
|
64
|
+
// (10 do usuario + 10 da IA). O resto fica no PC.
|
|
65
|
+
const trimmed = trimHistory(this.messages);
|
|
61
66
|
return {
|
|
62
67
|
sessionId: this.sessionId,
|
|
68
|
+
title:
|
|
69
|
+
this.title ||
|
|
70
|
+
(this.messages.find((m) => m.role === "user")?.text || "").slice(0, 120) ||
|
|
71
|
+
null,
|
|
63
72
|
model: this.model,
|
|
64
73
|
cwd: this.cwd,
|
|
65
74
|
allowAll: this.allowAll,
|
|
66
75
|
busy: this.busy,
|
|
67
76
|
streaming: this.streaming,
|
|
68
|
-
messages:
|
|
77
|
+
messages: trimmed.messages,
|
|
78
|
+
historyTruncated: trimmed.truncated,
|
|
79
|
+
historyTotal: trimmed.total,
|
|
69
80
|
context: this.context,
|
|
70
81
|
pendingApproval: this.pendingApproval
|
|
71
82
|
? {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Captcha proprio do kafox mobile — sem dependencia externa (sem node:crypto
|
|
3
|
+
* extra, sem servico de terceiros). Desafio aritmetico simples, renderizado
|
|
4
|
+
* como SVG inline no cliente, resolvido no servidor.
|
|
5
|
+
*
|
|
6
|
+
* Modelo de seguranca:
|
|
7
|
+
* - cada desafio e de USO UNICO e expira em CAPTCHA_TTL_MS;
|
|
8
|
+
* - a resposta fica guardada como hash (sha256), nunca em texto;
|
|
9
|
+
* - apos K tentativas o desafio morre (evita forca bruta);
|
|
10
|
+
* - limpeza oportunista dos expirados a cada emissao.
|
|
11
|
+
*
|
|
12
|
+
* Nao substitui Cloudflare Turnstile/hCaptcha, mas corta bots ingenuos e
|
|
13
|
+
* scripts que so reenviam o codigo do QR, que e o suficiente aqui.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const crypto = require("crypto");
|
|
17
|
+
|
|
18
|
+
const CAPTCHA_TTL_MS = 3 * 60 * 1000; // 3 minutos
|
|
19
|
+
const MAX_ATTEMPTS = 5;
|
|
20
|
+
|
|
21
|
+
/** @type {Map<string, {hash:string, expires:number, tries:number}>} */
|
|
22
|
+
const store = new Map();
|
|
23
|
+
|
|
24
|
+
function hash(text) {
|
|
25
|
+
return crypto.createHash("sha256").update(String(text).trim().toLowerCase()).digest("hex");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sweep() {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
for (const [id, entry] of store) {
|
|
31
|
+
if (entry.expires <= now) store.delete(id);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function randomInt(min, max) {
|
|
36
|
+
// inteiro uniforme em [min, max] usando bytes aleatorios
|
|
37
|
+
const span = max - min + 1;
|
|
38
|
+
const buf = crypto.randomBytes(4);
|
|
39
|
+
const n = buf.readUInt32BE(0);
|
|
40
|
+
return min + (n % span);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Emite um novo desafio.
|
|
45
|
+
* @returns {{ id:string, question:string, expiresAt:number }}
|
|
46
|
+
*/
|
|
47
|
+
function issue() {
|
|
48
|
+
sweep();
|
|
49
|
+
const a = randomInt(2, 9);
|
|
50
|
+
const b = randomInt(2, 9);
|
|
51
|
+
const usePlus = randomInt(0, 1) === 0;
|
|
52
|
+
let answer;
|
|
53
|
+
let question;
|
|
54
|
+
if (usePlus) {
|
|
55
|
+
answer = a + b;
|
|
56
|
+
question = `${a} + ${b} = ?`;
|
|
57
|
+
} else {
|
|
58
|
+
// subtracao sempre positiva
|
|
59
|
+
const hi = Math.max(a, b);
|
|
60
|
+
const lo = Math.min(a, b);
|
|
61
|
+
answer = hi - lo;
|
|
62
|
+
question = `${hi} - ${lo} = ?`;
|
|
63
|
+
}
|
|
64
|
+
const id = crypto.randomBytes(16).toString("hex");
|
|
65
|
+
store.set(id, { hash: hash(answer), expires: Date.now() + CAPTCHA_TTL_MS, tries: 0 });
|
|
66
|
+
return { id, question, expiresAt: Date.now() + CAPTCHA_TTL_MS };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Verifica e CONSOME o desafio (uso unico).
|
|
71
|
+
* @returns {{ ok:boolean, reason?:string }}
|
|
72
|
+
*/
|
|
73
|
+
function verify(id, answer) {
|
|
74
|
+
const entry = store.get(String(id || ""));
|
|
75
|
+
if (!entry) return { ok: false, reason: "captcha expirado ou invalido" };
|
|
76
|
+
if (entry.expires <= Date.now()) {
|
|
77
|
+
store.delete(id);
|
|
78
|
+
return { ok: false, reason: "captcha expirado" };
|
|
79
|
+
}
|
|
80
|
+
entry.tries += 1;
|
|
81
|
+
if (entry.tries > MAX_ATTEMPTS) {
|
|
82
|
+
store.delete(id);
|
|
83
|
+
return { ok: false, reason: "tentativas demais — recarregue o captcha" };
|
|
84
|
+
}
|
|
85
|
+
if (entry.hash !== hash(answer)) {
|
|
86
|
+
return { ok: false, reason: "captcha incorreto" };
|
|
87
|
+
}
|
|
88
|
+
store.delete(id); // uso unico
|
|
89
|
+
return { ok: true };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function size() {
|
|
93
|
+
return store.size;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { issue, verify, size, CAPTCHA_TTL_MS };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recorte de historico para o celular.
|
|
3
|
+
*
|
|
4
|
+
* Regra do produto: o celular NUNCA sobe o chat inteiro. Sobe no maximo
|
|
5
|
+
* MAX_USER mensagens do usuario + MAX_ASSISTANT respostas da IA (as mais
|
|
6
|
+
* recentes), deixando a conversa leve no primeiro carregamento.
|
|
7
|
+
*
|
|
8
|
+
* O recorte preserva a ordem cronologica e, quando ha varias, prioriza as
|
|
9
|
+
* mensagens recentes de cada papel. Turnos auxiliares (system/erro/compaction)
|
|
10
|
+
* que caiam entre as selecionadas sao mantidos para o contexto fazer sentido.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const MAX_USER = 10;
|
|
14
|
+
const MAX_ASSISTANT = 10;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {Array<{role:string}>} messages
|
|
18
|
+
* @param {{ maxUser?:number, maxAssistant?:number }} [opts]
|
|
19
|
+
* @returns {{ messages:Array, truncated:boolean, total:number, shown:number }}
|
|
20
|
+
*/
|
|
21
|
+
function trimHistory(messages, opts = {}) {
|
|
22
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
23
|
+
const maxUser = Number.isInteger(opts.maxUser) ? opts.maxUser : MAX_USER;
|
|
24
|
+
const maxAssistant = Number.isInteger(opts.maxAssistant) ? opts.maxAssistant : MAX_ASSISTANT;
|
|
25
|
+
|
|
26
|
+
// Conta quantas ainda devemos MANTER, varrendo do fim pro inicio.
|
|
27
|
+
let userLeft = maxUser;
|
|
28
|
+
let assistantLeft = maxAssistant;
|
|
29
|
+
const keep = new Array(list.length).fill(false);
|
|
30
|
+
|
|
31
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
32
|
+
const role = list[i] && list[i].role;
|
|
33
|
+
if (role === "user") {
|
|
34
|
+
if (userLeft > 0) {
|
|
35
|
+
keep[i] = true;
|
|
36
|
+
userLeft -= 1;
|
|
37
|
+
}
|
|
38
|
+
} else if (role === "assistant") {
|
|
39
|
+
if (assistantLeft > 0) {
|
|
40
|
+
keep[i] = true;
|
|
41
|
+
assistantLeft -= 1;
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
// system / error / compaction: acompanha o vizinho mais proximo que ficou.
|
|
45
|
+
// Mantem provisoriamente; a segunda passada remove orfaos das bordas.
|
|
46
|
+
keep[i] = true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Remove auxiliares nas bordas que nao tem nenhum user/assistant mantido
|
|
51
|
+
// depois (ou antes) deles — evita abrir o chat com uma linha solta.
|
|
52
|
+
let first = keep.indexOf(true);
|
|
53
|
+
let last = keep.lastIndexOf(true);
|
|
54
|
+
if (first === -1) return { messages: [], truncated: list.length > 0, total: list.length, shown: 0 };
|
|
55
|
+
while (first <= last && !isKeptTurn(list[first])) first++;
|
|
56
|
+
while (last >= first && !isKeptTurn(list[last])) last--;
|
|
57
|
+
|
|
58
|
+
const out = [];
|
|
59
|
+
for (let i = first; i <= last; i++) {
|
|
60
|
+
if (keep[i]) out.push(list[i]);
|
|
61
|
+
}
|
|
62
|
+
const truncated = out.length < list.length;
|
|
63
|
+
return { messages: out, truncated, total: list.length, shown: out.length };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isKeptTurn(m) {
|
|
67
|
+
const role = m && m.role;
|
|
68
|
+
return role === "user" || role === "assistant";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { trimHistory, MAX_USER, MAX_ASSISTANT };
|
package/src/mobile/index.js
CHANGED
|
@@ -309,6 +309,24 @@ async function runMobile({
|
|
|
309
309
|
console.log(chalk.dim(`\n${tail}\n`));
|
|
310
310
|
note(tail);
|
|
311
311
|
|
|
312
|
+
// O celular confirmou o modal e se pareou: some com o QR. Em terminal
|
|
313
|
+
// interativo limpamos a tela e redesenhamos sem o QR; no daemon (log em
|
|
314
|
+
// arquivo) so registramos a linha de sucesso. Usa onPaired (evento real),
|
|
315
|
+
// nao polling de activeTokens(), pra nao confundir token antigo do disco
|
|
316
|
+
// com um pareamento novo.
|
|
317
|
+
function onPaired(ev) {
|
|
318
|
+
const who = ev?.device?.name || "celular";
|
|
319
|
+
const ok = `✓ celular pareado (${who}) · abra ${base}`;
|
|
320
|
+
if (process.stdout.isTTY) {
|
|
321
|
+
process.stdout.write("\x1b[2J\x1b[H"); // limpa e volta ao topo
|
|
322
|
+
console.log(chalk.green(`\n${ok}\n`));
|
|
323
|
+
} else {
|
|
324
|
+
console.log(chalk.green(`\n${ok}\n`));
|
|
325
|
+
}
|
|
326
|
+
note(ok);
|
|
327
|
+
}
|
|
328
|
+
pairing.onPaired = onPaired;
|
|
329
|
+
|
|
312
330
|
await new Promise((resolve) => {
|
|
313
331
|
const shutdown = () => {
|
|
314
332
|
pairing.audit("shutdown", {});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limit do kafox mobile — janela deslizante por chave (IP + rota).
|
|
3
|
+
* Sem dependencia: mapa em memoria, poda periodica, sem timer pendurado.
|
|
4
|
+
*
|
|
5
|
+
* Objetivo: frear brute-force nas rotas publicas (adivinhar pair_code/sessao
|
|
6
|
+
* pela URL). Conta por IP com janela curta e teto generoso o bastante pra um
|
|
7
|
+
* usuario de verdade nem perceber, mas curto o bastante pra inviabilizar
|
|
8
|
+
* varredura de codigos.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const DEFAULTS = {
|
|
12
|
+
// POST /pair: tentativas de pareamento por IP.
|
|
13
|
+
pair: { max: 10, windowMs: 60_000 },
|
|
14
|
+
// GET /pair (poll): consulta de status do pedido. Por IP.
|
|
15
|
+
poll: { max: 120, windowMs: 60_000 },
|
|
16
|
+
// GET /captcha: emitir desafios (evita farmar captcha).
|
|
17
|
+
captcha: { max: 30, windowMs: 60_000 },
|
|
18
|
+
// Rotas autenticadas por token: teto por TOKEN (nao so por IP), pra frear
|
|
19
|
+
// quem varre a URL adivinhando sessao mesmo atras de um unico IP.
|
|
20
|
+
auth: { max: 150, windowMs: 60_000 },
|
|
21
|
+
// Tentativas de token INVALIDO por IP: piso curto. Bater aqui = varredura.
|
|
22
|
+
authFail: { max: 20, windowMs: 60_000 },
|
|
23
|
+
// POST /prompt e /approve: acoes que mexem no agente. Por token.
|
|
24
|
+
action: { max: 40, windowMs: 60_000 },
|
|
25
|
+
// GET / (a propria pagina) + qualquer rota nao classificada.
|
|
26
|
+
global: { max: 240, windowMs: 60_000 },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
class SlideLimiter {
|
|
30
|
+
constructor(now = () => Date.now()) {
|
|
31
|
+
this.buckets = new Map();
|
|
32
|
+
this.now = now;
|
|
33
|
+
this.lastPrune = 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Registra um hit. Devolve { ok, retryAfter } — ok=false quando estourou.
|
|
38
|
+
* @param {string} key ex: "pair:1.2.3.4"
|
|
39
|
+
* @param {{max:number, windowMs:number}} rule
|
|
40
|
+
*/
|
|
41
|
+
hit(key, rule) {
|
|
42
|
+
const now = this.now();
|
|
43
|
+
this.maybePrune(now);
|
|
44
|
+
const hits = this.buckets.get(key) || [];
|
|
45
|
+
// descarta timestamps fora da janela (sliding window)
|
|
46
|
+
const cutoff = now - rule.windowMs;
|
|
47
|
+
let i = 0;
|
|
48
|
+
while (i < hits.length && hits[i] <= cutoff) i += 1;
|
|
49
|
+
if (i > 0) hits.splice(0, i);
|
|
50
|
+
|
|
51
|
+
if (hits.length >= rule.max) {
|
|
52
|
+
const retryAfter = Math.max(1, Math.ceil((hits[0] + rule.windowMs - now) / 1000));
|
|
53
|
+
this.buckets.set(key, hits);
|
|
54
|
+
return { ok: false, retryAfter };
|
|
55
|
+
}
|
|
56
|
+
hits.push(now);
|
|
57
|
+
this.buckets.set(key, hits);
|
|
58
|
+
return { ok: true, remaining: rule.max - hits.length };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
maybePrune(now) {
|
|
62
|
+
if (now - this.lastPrune < 60_000) return;
|
|
63
|
+
this.lastPrune = now;
|
|
64
|
+
const longest = 5 * 60_000;
|
|
65
|
+
for (const [key, hits] of this.buckets) {
|
|
66
|
+
if (!hits.length || hits[hits.length - 1] < now - longest) this.buckets.delete(key);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
size() {
|
|
71
|
+
return this.buckets.size;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
reset() {
|
|
75
|
+
this.buckets.clear();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { SlideLimiter, DEFAULTS };
|
package/src/mobile/server.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
const http = require("http");
|
|
18
18
|
const { PairingManager } = require("./auth");
|
|
19
19
|
const { renderPage } = require("./view");
|
|
20
|
+
const captcha = require("./captcha");
|
|
21
|
+
const { SlideLimiter, DEFAULTS } = require("./ratelimit");
|
|
20
22
|
|
|
21
23
|
const HEARTBEAT_MS = 5_000;
|
|
22
24
|
|
|
@@ -48,8 +50,38 @@ function json(res, status, body) {
|
|
|
48
50
|
function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml = renderPage() } = {}) {
|
|
49
51
|
if (!bridge) throw new Error("createMobileServer precisa de um SessionBridge");
|
|
50
52
|
|
|
53
|
+
const limiter = new SlideLimiter();
|
|
51
54
|
const sseClients = new Set();
|
|
52
55
|
|
|
56
|
+
// IP do cliente. Atras do Caddy (reverse_proxy) vem em x-forwarded-for; em
|
|
57
|
+
// acesso direto, o socket. Normaliza pro primeiro IP da cadeia.
|
|
58
|
+
function clientIp(req) {
|
|
59
|
+
const fwd = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim();
|
|
60
|
+
return fwd || req.socket.remoteAddress || "desconhecido";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Aplica o rate limit da rota. Devolve true se JA respondeu (bloqueado).
|
|
64
|
+
function blocked(req, res, ruleName, overrideKey) {
|
|
65
|
+
const rule = DEFAULTS[ruleName];
|
|
66
|
+
if (!rule) return false;
|
|
67
|
+
const key = `${ruleName}:${overrideKey || clientIp(req)}`;
|
|
68
|
+
const verdict = limiter.hit(key, rule);
|
|
69
|
+
if (verdict.ok) return false;
|
|
70
|
+
res.writeHead(429, {
|
|
71
|
+
"content-type": "application/json; charset=utf-8",
|
|
72
|
+
"retry-after": String(verdict.retryAfter),
|
|
73
|
+
"cache-control": "no-store",
|
|
74
|
+
});
|
|
75
|
+
res.end(
|
|
76
|
+
JSON.stringify({
|
|
77
|
+
error: "muitas requisições — aguarde alguns instantes",
|
|
78
|
+
code: "rate_limited",
|
|
79
|
+
retryAfter: verdict.retryAfter,
|
|
80
|
+
})
|
|
81
|
+
);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
53
85
|
function broadcast(event) {
|
|
54
86
|
const frame = `data: ${JSON.stringify(event)}\n\n`;
|
|
55
87
|
for (const client of sseClients) {
|
|
@@ -67,6 +99,10 @@ function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml =
|
|
|
67
99
|
const url = new URL(req.url, "http://localhost");
|
|
68
100
|
const pathname = url.pathname;
|
|
69
101
|
|
|
102
|
+
// Teto global por IP: nenhuma rota escapa de um piso minimo de protecao
|
|
103
|
+
// contra varredura de URLs.
|
|
104
|
+
if (blocked(req, res, "global")) return;
|
|
105
|
+
|
|
70
106
|
// A pagina e o proprio endpoint de pareamento nao exigem token.
|
|
71
107
|
if (req.method === "GET" && (pathname === "/" || pathname === "/index.html")) {
|
|
72
108
|
res.writeHead(200, {
|
|
@@ -94,30 +130,94 @@ function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml =
|
|
|
94
130
|
return;
|
|
95
131
|
}
|
|
96
132
|
|
|
133
|
+
// CAPTCHA: emite um desafio novo (publico, de uso unico). O celular pede
|
|
134
|
+
// isso antes de tentar parear. Sem ele, o POST /pair e rejeitado.
|
|
135
|
+
if (req.method === "GET" && pathname === "/captcha") {
|
|
136
|
+
if (blocked(req, res, "captcha")) return;
|
|
137
|
+
return json(res, 200, captcha.issue());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// PASSO 1: o celular pede entrada (com o codigo do QR). Nao recebe token
|
|
141
|
+
// ainda — cria um pedido pendente e o dono do terminal aprova/nega.
|
|
97
142
|
if (req.method === "POST" && pathname === "/pair") {
|
|
143
|
+
if (blocked(req, res, "pair")) return;
|
|
98
144
|
let body;
|
|
99
145
|
try {
|
|
100
146
|
body = JSON.parse((await readBody(req)) || "{}");
|
|
101
147
|
} catch {
|
|
102
148
|
return json(res, 400, { error: "json invalido" });
|
|
103
149
|
}
|
|
150
|
+
// Portao anti-bot: valida e consome o captcha ANTES de olhar o codigo.
|
|
151
|
+
const captchaCheck = captcha.verify(body.captchaId, body.captchaAnswer);
|
|
152
|
+
if (!captchaCheck.ok) {
|
|
153
|
+
return json(res, 400, { error: captchaCheck.reason || "captcha invalido", captcha: true });
|
|
154
|
+
}
|
|
104
155
|
const code = String(body.code || "").trim().toUpperCase();
|
|
105
156
|
const device = {
|
|
106
157
|
ua: String(req.headers["user-agent"] || "").slice(0, 160),
|
|
107
158
|
ip: String(req.headers["x-forwarded-for"] || req.socket.remoteAddress || "").slice(0, 80),
|
|
108
159
|
name: String(body.device || "celular").slice(0, 60),
|
|
109
160
|
};
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
161
|
+
const snap = bridge.snapshot();
|
|
162
|
+
const request = pairing.requestPair(code, device, {
|
|
163
|
+
sessionTitle: snap.title || snap.name || "",
|
|
164
|
+
sessionId: snap.sessionId || "",
|
|
165
|
+
});
|
|
166
|
+
if (!request) return json(res, 401, { error: "codigo invalido ou expirado" });
|
|
167
|
+
return json(res, 200, {
|
|
168
|
+
requestId: request.requestId,
|
|
169
|
+
expiresAt: request.expiresAt,
|
|
170
|
+
status: "pending",
|
|
171
|
+
session: { sessionId: snap.sessionId, title: snap.title || snap.name || "" },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// PASSO 3: o celular consulta o status do pedido. Quando aprovado, recebe o
|
|
176
|
+
// token UMA vez. Rota publica (autenticada pelo requestId aleatorio).
|
|
177
|
+
if (req.method === "GET" && pathname === "/pair") {
|
|
178
|
+
if (blocked(req, res, "poll")) return;
|
|
179
|
+
const requestId = String(url.searchParams.get("id") || "");
|
|
180
|
+
if (!requestId) return json(res, 400, { error: "id obrigatorio" });
|
|
181
|
+
const status = pairing.pollRequest(requestId);
|
|
182
|
+
if (status.status === "unknown") return json(res, 404, { error: "pedido nao encontrado" });
|
|
183
|
+
if (status.status === "approved") {
|
|
184
|
+
return json(res, 200, { status: "approved", token: status.token, expiresAt: status.expiresAt, session: bridge.snapshot() });
|
|
185
|
+
}
|
|
186
|
+
return json(res, 200, { status: status.status });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// PASSO 2: o terminal aprova/nega um pedido pendente. Exige token de um
|
|
190
|
+
// pareamento JA existente OU vem do proprio dono (rota local). Aqui so
|
|
191
|
+
// aceitamos quando ha um pedido valido — a rota e usada pela TUI no PC.
|
|
192
|
+
if (req.method === "POST" && pathname === "/pair/decide") {
|
|
193
|
+
let body;
|
|
194
|
+
try {
|
|
195
|
+
body = JSON.parse((await readBody(req)) || "{}");
|
|
196
|
+
} catch {
|
|
197
|
+
return json(res, 400, { error: "json invalido" });
|
|
198
|
+
}
|
|
199
|
+
const requestId = String(body.id || "");
|
|
200
|
+
const approved = Boolean(body.approved);
|
|
201
|
+
if (!requestId) return json(res, 400, { error: "id obrigatorio" });
|
|
202
|
+
const ok = pairing.resolveRequest(requestId, approved);
|
|
203
|
+
if (!ok) return json(res, 404, { error: "pedido nao encontrado ou ja resolvido" });
|
|
204
|
+
return json(res, 200, { ok: true, status: approved ? "approved" : "denied" });
|
|
113
205
|
}
|
|
114
206
|
|
|
115
207
|
// A partir daqui, exige token de sessao.
|
|
116
208
|
const token = req.headers["x-kafox-token"] || url.searchParams.get("t");
|
|
117
209
|
if (!pairing.validateToken(token)) {
|
|
210
|
+
// Teto curto por IP para TOKEN INVALIDO: bater aqui e sinal de varredura
|
|
211
|
+
// de sessao pela URL. Vale mesmo antes de responder o 401.
|
|
212
|
+
if (blocked(req, res, "authFail")) return;
|
|
118
213
|
return json(res, 401, { error: "sem token valido" });
|
|
119
214
|
}
|
|
120
215
|
|
|
216
|
+
// Token valido: conta por TOKEN (nao so por IP), pra um IP sozinho nao
|
|
217
|
+
// conseguir varrer sessoes sem estourar o bucket da propria sessao.
|
|
218
|
+
const tokenKey = String(token).slice(0, 16);
|
|
219
|
+
if (blocked(req, res, "auth", tokenKey)) return;
|
|
220
|
+
|
|
121
221
|
if (req.method === "GET" && pathname === "/state") {
|
|
122
222
|
return json(res, 200, bridge.snapshot());
|
|
123
223
|
}
|
|
@@ -195,6 +295,7 @@ function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml =
|
|
|
195
295
|
}
|
|
196
296
|
|
|
197
297
|
if (req.method === "POST" && pathname === "/prompt") {
|
|
298
|
+
if (blocked(req, res, "action", tokenKey)) return;
|
|
198
299
|
let body;
|
|
199
300
|
try {
|
|
200
301
|
body = JSON.parse((await readBody(req)) || "{}");
|
|
@@ -211,6 +312,7 @@ function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml =
|
|
|
211
312
|
}
|
|
212
313
|
|
|
213
314
|
if (req.method === "POST" && pathname === "/approve") {
|
|
315
|
+
if (blocked(req, res, "action", tokenKey)) return;
|
|
214
316
|
let body;
|
|
215
317
|
try {
|
|
216
318
|
body = JSON.parse((await readBody(req)) || "{}");
|
package/src/mobile/tui-bridge.js
CHANGED
|
@@ -71,6 +71,7 @@ class TuiBridge extends EventEmitter {
|
|
|
71
71
|
snapshot() {
|
|
72
72
|
return {
|
|
73
73
|
sessionId: this.sessionId,
|
|
74
|
+
title: this.state.title || (this.state.messages || []).find((m) => m.role === "user")?.text || null,
|
|
74
75
|
model: this.state.model || null,
|
|
75
76
|
cwd: this.cwd || process.cwd(),
|
|
76
77
|
allowAll: Boolean(this.state.autoApprove),
|
package/src/mobile/view.js
CHANGED
|
@@ -167,6 +167,36 @@ function renderPage() {
|
|
|
167
167
|
#pairing .code:focus { outline:none; box-shadow:4px 4px 0 0 var(--neon); }
|
|
168
168
|
#pairing .err { color:var(--err); min-height:1.2em; font-size:12px; }
|
|
169
169
|
#pairing button { width:min(92vw,420px); }
|
|
170
|
+
/* captcha: desafio em SVG + campo curto, lado a lado */
|
|
171
|
+
#pairing .captcha { display:flex; align-items:center; gap:10px; width:min(92vw,420px); }
|
|
172
|
+
#pairing .captcha .q { flex:1; height:56px; border:2px solid var(--neon); background:#000;
|
|
173
|
+
display:flex; align-items:center; justify-content:center; }
|
|
174
|
+
#pairing .captcha .q svg { display:block; }
|
|
175
|
+
#pairing .captcha .reload { width:44px; height:56px; flex:0 0 auto; border:2px solid var(--neon);
|
|
176
|
+
background:#000; color:var(--neon); font-size:18px; line-height:1; cursor:pointer; padding:0; }
|
|
177
|
+
#pairing .captcha .reload:active { background:var(--neon); color:#000; }
|
|
178
|
+
#pairing input.small { font-size:22px; font-weight:800; letter-spacing:0.2em; text-align:center;
|
|
179
|
+
border:2px solid var(--neon); background:#000; color:var(--neon); padding:12px;
|
|
180
|
+
width:min(92vw,420px); }
|
|
181
|
+
#pairing input.small:focus { outline:none; box-shadow:4px 4px 0 0 var(--neon); }
|
|
182
|
+
#pairing .hint { color:var(--dim); font-size:11px; letter-spacing:0.06em; max-width:34ch; }
|
|
183
|
+
|
|
184
|
+
/* modal de confirmacao de entrada (pedido de pareamento) */
|
|
185
|
+
#confirm { position:fixed; inset:0; background:rgba(0,0,0,0.92); z-index:21; display:none;
|
|
186
|
+
flex-direction:column; align-items:center; justify-content:center; gap:14px; padding:24px;
|
|
187
|
+
text-align:center; }
|
|
188
|
+
#confirm.show { display:flex; }
|
|
189
|
+
#confirm h2 { font-size:16px; text-transform:uppercase; letter-spacing:0.14em; margin:0; color:var(--neon); }
|
|
190
|
+
#confirm p { color:var(--dim); font-size:12.5px; margin:0; max-width:34ch; line-height:1.6; }
|
|
191
|
+
#confirm .chat { color:#fff; font-size:15px; font-weight:700; border:2px solid #fff;
|
|
192
|
+
padding:10px 16px; max-width:min(92vw,420px); word-break:break-word; min-height:1.2em; }
|
|
193
|
+
#confirm .chat:empty { display:none; }
|
|
194
|
+
#confirm .err { color:var(--err); min-height:1.2em; font-size:12.5px; max-width:34ch; }
|
|
195
|
+
#confirm .wait { color:var(--neon); font-size:13px; letter-spacing:0.1em; }
|
|
196
|
+
#confirm button { width:min(92vw,420px); }
|
|
197
|
+
#confirm[data-state="waiting"] #confirmBtn,
|
|
198
|
+
#confirm[data-state="waiting"] #confirmCancel { display:none; }
|
|
199
|
+
#confirm:not([data-state="waiting"]) .wait { display:none; }
|
|
170
200
|
|
|
171
201
|
/* Pensamento recolhido: mostra so a primeira linha como "resumo" clicavel. */
|
|
172
202
|
.ln.think { cursor:pointer; }
|
|
@@ -196,9 +226,24 @@ function renderPage() {
|
|
|
196
226
|
<p>se a câmera não leu o QR, digite o código que aparece embaixo dele na tela do PC.</p>
|
|
197
227
|
<input class="code" id="paircode" inputmode="text" autocomplete="off" autocapitalize="characters"
|
|
198
228
|
spellcheck="false" placeholder="CÓDIGO" maxlength="12" />
|
|
229
|
+
<div class="hint" id="captchaHint">resolva a conta pra provar que você é humano:</div>
|
|
230
|
+
<div class="captcha">
|
|
231
|
+
<div class="q" id="captchaQ"></div>
|
|
232
|
+
<button class="reload" id="captchaReload" type="button" title="outro desafio" aria-label="outro desafio">↻</button>
|
|
233
|
+
</div>
|
|
234
|
+
<input class="small" id="captchaA" inputmode="numeric" autocomplete="off" placeholder="?" maxlength="3" />
|
|
199
235
|
<div class="err" id="pairerr"></div>
|
|
200
236
|
<button class="primary" id="pairbtn">parear</button>
|
|
201
237
|
</div>
|
|
238
|
+
<div id="confirm">
|
|
239
|
+
<h2>entrar no chat?</h2>
|
|
240
|
+
<p id="confirmText">você vai pedir acesso ao terminal deste PC.</p>
|
|
241
|
+
<div class="chat" id="confirmTitle"></div>
|
|
242
|
+
<div class="err" id="confirmErr"></div>
|
|
243
|
+
<div class="wait" id="confirmWait">aguardando aprovação no PC…</div>
|
|
244
|
+
<button class="primary" id="confirmBtn">entrar</button>
|
|
245
|
+
<button id="confirmCancel">cancelar</button>
|
|
246
|
+
</div>
|
|
202
247
|
</div>
|
|
203
248
|
<script>
|
|
204
249
|
(function () {
|
|
@@ -414,30 +459,190 @@ function renderPage() {
|
|
|
414
459
|
return BASE.replace(/\\/$/, "") + "/" + String(path).replace(/^\\//, "");
|
|
415
460
|
}
|
|
416
461
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
462
|
+
// PASSO 1: pede entrada. NAO recebe token ainda — o dono do terminal aprova.
|
|
463
|
+
// Devolve { requestId, session } ou null.
|
|
464
|
+
async function requestEntry(code) {
|
|
465
|
+
if (!code) return null;
|
|
466
|
+
var res, data;
|
|
420
467
|
try {
|
|
421
468
|
res = await fetch(url("pair"), {
|
|
422
469
|
method: "POST", headers: { "content-type": "application/json" },
|
|
423
|
-
body: JSON.stringify({
|
|
470
|
+
body: JSON.stringify({
|
|
471
|
+
code: code,
|
|
472
|
+
device: navigator.platform || "celular",
|
|
473
|
+
captchaId: captchaId,
|
|
474
|
+
captchaAnswer: ($("captchaA") && $("captchaA").value.trim()) || "",
|
|
475
|
+
}),
|
|
424
476
|
});
|
|
425
477
|
} catch (e) {
|
|
426
478
|
pairErr("sem ligação com o servidor — confira a URL");
|
|
427
|
-
return
|
|
479
|
+
return null;
|
|
428
480
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
481
|
+
try { data = await res.json(); } catch (e) { data = {}; }
|
|
482
|
+
if (!res.ok) {
|
|
483
|
+
if (data && data.captcha) {
|
|
484
|
+
pairErr("captcha incorreto — tente de novo");
|
|
485
|
+
loadCaptcha(); // desafio e de uso unico: pede outro
|
|
486
|
+
} else {
|
|
487
|
+
pairErr((data && data.error) || "código inválido ou expirado — gere outro no PC");
|
|
488
|
+
}
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
return data;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// --- captcha anti-bot (desafio emitido pelo servidor) ---
|
|
495
|
+
var captchaId = "";
|
|
496
|
+
var captchaSvg = null;
|
|
497
|
+
|
|
498
|
+
// Desenha a pergunta como SVG com leve ruido/rotacao (dificulta OCR simples),
|
|
499
|
+
// tudo em texto vetorial — nada de canvas/imagem externa.
|
|
500
|
+
function renderCaptchaSvg(question, seed) {
|
|
501
|
+
var chars = String(question).split("");
|
|
502
|
+
var pad = 10, cw = 15, fs = 24;
|
|
503
|
+
var w = pad * 2 + chars.length * cw;
|
|
504
|
+
var h = 56;
|
|
505
|
+
var parts = ['<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + " " + h + '">'];
|
|
506
|
+
parts.push('<rect width="' + w + '" height="' + h + '" fill="#000"/>');
|
|
507
|
+
// algumas linhas de ruido
|
|
508
|
+
var rnd = function (n) { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed % n; };
|
|
509
|
+
for (var k = 0; k < 4; k++) {
|
|
510
|
+
var x1 = rnd(w), y1 = rnd(h), x2 = rnd(w), y2 = rnd(h);
|
|
511
|
+
parts.push('<line x1="' + x1 + '" y1="' + y1 + '" x2="' + x2 + '" y2="' + y2 + '" stroke="#bfa682" stroke-width="1" opacity="0.35"/>');
|
|
512
|
+
}
|
|
513
|
+
for (var i = 0; i < chars.length; i++) {
|
|
514
|
+
var ch = chars[i];
|
|
515
|
+
if (ch === " ") { continue; }
|
|
516
|
+
var rot = (rnd(17) - 8);
|
|
517
|
+
var y = Math.round(h / 2 + 6 + (rnd(7) - 3));
|
|
518
|
+
var x = pad + i * cw;
|
|
519
|
+
parts.push(
|
|
520
|
+
'<text x="' + x + '" y="' + y + '" font-family="monospace" font-size="' + fs +
|
|
521
|
+
'" font-weight="700" fill="#bfa682" transform="rotate(' + rot + " " + x + " " + y + ')">' +
|
|
522
|
+
ch.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") +
|
|
523
|
+
"</text>"
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
parts.push("</svg>");
|
|
527
|
+
return parts.join("");
|
|
436
528
|
}
|
|
437
529
|
|
|
438
|
-
function
|
|
530
|
+
async function loadCaptcha() {
|
|
531
|
+
var box = $("captchaQ");
|
|
532
|
+
if (box) box.textContent = "…";
|
|
533
|
+
try {
|
|
534
|
+
var res = await fetch(url("captcha"), { cache: "no-store" });
|
|
535
|
+
var data = await res.json();
|
|
536
|
+
captchaId = data.id || "";
|
|
537
|
+
var seed = 0;
|
|
538
|
+
for (var i = 0; i < captchaId.length; i++) seed = (seed * 31 + captchaId.charCodeAt(i)) & 0x7fffffff;
|
|
539
|
+
if (box) box.innerHTML = renderCaptchaSvg(data.question || "", seed);
|
|
540
|
+
var a = $("captchaA");
|
|
541
|
+
if (a) { a.value = ""; }
|
|
542
|
+
} catch (e) {
|
|
543
|
+
captchaId = "";
|
|
544
|
+
if (box) box.textContent = "falha ao carregar";
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function showPairing(on) {
|
|
549
|
+
$("pairing").classList[on ? "add" : "remove"]("show");
|
|
550
|
+
if (on && !captchaId) loadCaptcha();
|
|
551
|
+
}
|
|
439
552
|
function pairErr(msg) { $("pairerr").textContent = msg || ""; }
|
|
440
553
|
|
|
554
|
+
// Modal de confirmacao: mostra o titulo do chat e pede "entrar".
|
|
555
|
+
function showConfirm(state, msg) {
|
|
556
|
+
var el = $("confirm");
|
|
557
|
+
if (!state) { el.classList.remove("show"); el.removeAttribute("data-state"); return; }
|
|
558
|
+
$("confirmErr").textContent = msg || "";
|
|
559
|
+
el.setAttribute("data-state", state);
|
|
560
|
+
el.classList.add("show");
|
|
561
|
+
}
|
|
562
|
+
function confirmErr(msg) { $("confirmErr").textContent = msg || ""; }
|
|
563
|
+
|
|
564
|
+
// Pareamento (Opção A): o celular se auto-aprova apos confirmar no modal.
|
|
565
|
+
// 1. pede entrada (sem token) -> cria um pedido PENDENTE no servidor;
|
|
566
|
+
// 2. mostra o modal "entrar no chat? <titulo>" com os botoes entrar/cancelar;
|
|
567
|
+
// ao clicar em "entrar", ESTE navegador chama /pair/decide {approved:true},
|
|
568
|
+
// o token de sessao e emitido para ele e o terminal detecta (some o QR).
|
|
569
|
+
// Cancelar nega o pedido (approved:false).
|
|
570
|
+
// Devolve true se pareou; false se negado/expirado/erro/cancelado.
|
|
571
|
+
function pair(code) {
|
|
572
|
+
if (!code) return false;
|
|
573
|
+
return new Promise(async function (resolve) {
|
|
574
|
+
// garante um captcha valido (fluxo QR nao passa por showPairing).
|
|
575
|
+
if (!captchaId) await loadCaptcha();
|
|
576
|
+
// valida o codigo primeiro (rapido). Se for valido, mostra direto o modal
|
|
577
|
+
// "entrar no chat?" — o proprio usuario confirma no celular.
|
|
578
|
+
var req = await requestEntry(code);
|
|
579
|
+
if (!req || !req.requestId) { showConfirm(null); resolve(false); return; }
|
|
580
|
+
$("confirmTitle").textContent = ((req.session && req.session.title) || req.title || "conversa sem título");
|
|
581
|
+
confirmErr("");
|
|
582
|
+
showConfirm("ask");
|
|
583
|
+
|
|
584
|
+
var settled = false;
|
|
585
|
+
function finish(ok, msg) {
|
|
586
|
+
if (settled) return;
|
|
587
|
+
settled = true;
|
|
588
|
+
$("confirmBtn").onclick = null;
|
|
589
|
+
$("confirmCancel").onclick = null;
|
|
590
|
+
if (ok) { showConfirm(null); resolve(true); }
|
|
591
|
+
else { showConfirm("ask", msg); resolve(false); }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function decide(approved) {
|
|
595
|
+
return fetch(url("pair/decide"), {
|
|
596
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
597
|
+
body: JSON.stringify({ id: req.requestId, approved: approved }),
|
|
598
|
+
}).catch(function () { return null; });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
$("confirmBtn").onclick = function () {
|
|
602
|
+
$("confirmBtn").disabled = true;
|
|
603
|
+
// aprova explicitamente ("tem certeza?") e espera o token desta sessao.
|
|
604
|
+
(async function () {
|
|
605
|
+
await decide(true);
|
|
606
|
+
var decision;
|
|
607
|
+
while (!settled) {
|
|
608
|
+
decision = await awaitDecisionOnce(req.requestId);
|
|
609
|
+
if (decision.status !== "pending") break;
|
|
610
|
+
await new Promise(function (r) { setTimeout(r, 1000); });
|
|
611
|
+
}
|
|
612
|
+
$("confirmBtn").disabled = false;
|
|
613
|
+
if (decision && decision.status === "approved") finish(true);
|
|
614
|
+
else if (decision && decision.status === "denied") finish(false, "entrada negada — gere outro código");
|
|
615
|
+
else finish(false, "pedido expirou — gere outro código no PC");
|
|
616
|
+
})();
|
|
617
|
+
};
|
|
618
|
+
$("confirmCancel").onclick = function () {
|
|
619
|
+
decide(false);
|
|
620
|
+
finish(false, "cancelado — escaneie o QR ou digite o código de novo");
|
|
621
|
+
};
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Uma consulta de status (sem loop), para o modal controlar o polling.
|
|
626
|
+
async function awaitDecisionOnce(requestId) {
|
|
627
|
+
var res;
|
|
628
|
+
try {
|
|
629
|
+
res = await fetch(url("pair") + "?id=" + encodeURIComponent(requestId), { cache: "no-store" });
|
|
630
|
+
} catch (e) {
|
|
631
|
+
return { status: "pending" };
|
|
632
|
+
}
|
|
633
|
+
if (res.status === 404) return { status: "expired" };
|
|
634
|
+
var data = await res.json().catch(function () { return {}; });
|
|
635
|
+
if (data.status === "approved" && data.token) {
|
|
636
|
+
token = data.token;
|
|
637
|
+
sessionStorage.setItem("kafox_token", token);
|
|
638
|
+
sid.textContent = (data.session && data.session.sessionId) || "";
|
|
639
|
+
return { status: "approved" };
|
|
640
|
+
}
|
|
641
|
+
if (data.status === "denied") return { status: "denied" };
|
|
642
|
+
if (data.status === "expired" || data.status === "unknown") return { status: "expired" };
|
|
643
|
+
return { status: "pending" };
|
|
644
|
+
}
|
|
645
|
+
|
|
441
646
|
// Reconstroi o log a partir do historico (um turno por mensagem do usuario,
|
|
442
647
|
// com as respostas/tools/pensamentos abaixo).
|
|
443
648
|
function renderHistory(messages) {
|
|
@@ -663,14 +868,17 @@ function renderPage() {
|
|
|
663
868
|
|
|
664
869
|
async function boot() {
|
|
665
870
|
var m = location.hash.match(/p=([A-Za-z0-9]+)/);
|
|
871
|
+
var scanned = m ? m[1] : "";
|
|
666
872
|
if (m) {
|
|
667
873
|
await pair(m[1]);
|
|
668
874
|
history.replaceState(null, "", location.pathname);
|
|
669
875
|
}
|
|
670
876
|
if (!token) {
|
|
671
877
|
// Sem token (nem por QR, nem salvo): mostra a tela de pareamento manual.
|
|
878
|
+
// Se veio do QR, deixa o codigo preenchido pra so resolver o captcha.
|
|
879
|
+
if (scanned && !$("paircode").value) $("paircode").value = scanned.toUpperCase();
|
|
672
880
|
showPairing(true);
|
|
673
|
-
$("
|
|
881
|
+
$("captchaA").focus();
|
|
674
882
|
return;
|
|
675
883
|
}
|
|
676
884
|
var okState = await loadState();
|
|
@@ -692,6 +900,7 @@ function renderPage() {
|
|
|
692
900
|
}
|
|
693
901
|
}
|
|
694
902
|
$("pairbtn").onclick = pairManual;
|
|
903
|
+
$("captchaReload").onclick = function () { pairErr(""); loadCaptcha(); };
|
|
695
904
|
$("paircode").addEventListener("input", function () {
|
|
696
905
|
this.value = this.value.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
697
906
|
});
|
package/src/tui.js
CHANGED
|
@@ -16,7 +16,7 @@ const os = require("os");
|
|
|
16
16
|
const path = require("path");
|
|
17
17
|
const chalk = require("chalk");
|
|
18
18
|
const storage = require("./storage");
|
|
19
|
-
const { estimateTokens: countContextTokens, compactIfNeeded } = require("./compact");
|
|
19
|
+
const { estimateTokens: countContextTokens, compactIfNeeded, trimHistory } = require("./compact");
|
|
20
20
|
const { ensureSession, fetchApi } = require("./auth");
|
|
21
21
|
const { runAgent } = require("./agent");
|
|
22
22
|
const { THEMES, themeColors, paintLine, paintPanel, borderColor } = require("./theme");
|
|
@@ -1408,6 +1408,29 @@ function box(state, { title, body, hint, width }) {
|
|
|
1408
1408
|
|
|
1409
1409
|
function approvalBody(state, width) {
|
|
1410
1410
|
const { name, args, change } = state.approval;
|
|
1411
|
+
|
|
1412
|
+
// Pedido de pareamento do celular: mesmo widget de opcoes, conteudo proprio.
|
|
1413
|
+
if (name === "__pair__") {
|
|
1414
|
+
const body = [];
|
|
1415
|
+
const who = change?.device || "celular";
|
|
1416
|
+
body.push(chalk.bold("Um celular quer entrar nesta conversa."));
|
|
1417
|
+
body.push("");
|
|
1418
|
+
if (change?.title) body.push(truncate("conversa: " + change.title, width - 4));
|
|
1419
|
+
if (change?.base) body.push(chalk.dim(truncate(change.base, width - 4)));
|
|
1420
|
+
body.push(chalk.dim(truncate("dispositivo: " + who, width - 4)));
|
|
1421
|
+
body.push("");
|
|
1422
|
+
const optionsStart = body.length;
|
|
1423
|
+
const opts = state.approval.options || APPROVAL_OPTIONS;
|
|
1424
|
+
for (const [index, option] of opts.entries()) {
|
|
1425
|
+
const selected = index === state.approval.index;
|
|
1426
|
+
const line = padRight(`${selected ? "▸ " : " "}${option.label}`, width - 6);
|
|
1427
|
+
if (selected) body.push(chalk.inverse(chalk.bold(line)));
|
|
1428
|
+
else if (option.id === "deny") body.push(chalk.dim(line));
|
|
1429
|
+
else body.push(line);
|
|
1430
|
+
}
|
|
1431
|
+
return { title: "Pareamento do celular", body, optionsStart };
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1411
1434
|
const meta = toolMeta(name);
|
|
1412
1435
|
const body = [];
|
|
1413
1436
|
|
|
@@ -2420,14 +2443,14 @@ async function runTUI({
|
|
|
2420
2443
|
text: "Interrompido por você (Esc).",
|
|
2421
2444
|
interrupted: true,
|
|
2422
2445
|
});
|
|
2423
|
-
if (result.messages) state.context = result.messages;
|
|
2446
|
+
if (result.messages) state.context = trimHistory(result.messages);
|
|
2424
2447
|
} else if (result.ok) {
|
|
2425
2448
|
const text = String(result.text || "").trim();
|
|
2426
2449
|
// O stream ja foi commitado em finishStreaming(): so empurra aqui se for
|
|
2427
2450
|
// texto novo (ou se nao houve streaming, ex.: resposta sem onText).
|
|
2428
2451
|
if (text) commitFinalText(state, text, result.ms);
|
|
2429
2452
|
state.streamedText = "";
|
|
2430
|
-
if (result.messages) state.context = result.messages;
|
|
2453
|
+
if (result.messages) state.context = trimHistory(result.messages);
|
|
2431
2454
|
// Reconectou de verdade: limpa o alerta e os erros de rede pendentes.
|
|
2432
2455
|
clearRetry();
|
|
2433
2456
|
} else {
|
|
@@ -2510,10 +2533,15 @@ async function runTUI({
|
|
|
2510
2533
|
const pending = state.approval;
|
|
2511
2534
|
if (!pending) return;
|
|
2512
2535
|
state.approval = null;
|
|
2513
|
-
|
|
2514
|
-
|
|
2536
|
+
// O pedido de pareamento NAO mexe no autoApprove de ferramentas: "lembrar"
|
|
2537
|
+
// ali significa confiar naquele navegador, tratado por quem criou o pedido.
|
|
2538
|
+
const isPair = pending.name === "__pair__";
|
|
2539
|
+
if (decision === "always" && !isPair) state.autoApprove = true;
|
|
2540
|
+
if (decision === "always" && !isPair) persist();
|
|
2515
2541
|
render();
|
|
2516
|
-
|
|
2542
|
+
// Pareamento precisa do texto da decisao (once/always/deny); ferramentas
|
|
2543
|
+
// so querem o booleano "aprovado?".
|
|
2544
|
+
pending.resolve(isPair ? decision : decision !== "deny");
|
|
2517
2545
|
}
|
|
2518
2546
|
|
|
2519
2547
|
// Com captura de mouse ligada o terminal entrega a roda para a TUI, mas em
|
|
@@ -2593,7 +2621,8 @@ async function runTUI({
|
|
|
2593
2621
|
}
|
|
2594
2622
|
|
|
2595
2623
|
function moveApproval(delta) {
|
|
2596
|
-
const
|
|
2624
|
+
const opts = state.approval.options || APPROVAL_OPTIONS;
|
|
2625
|
+
const count = opts.length;
|
|
2597
2626
|
state.approval.index = (state.approval.index + delta + count) % count;
|
|
2598
2627
|
render();
|
|
2599
2628
|
}
|
|
@@ -2610,10 +2639,11 @@ async function runTUI({
|
|
|
2610
2639
|
|
|
2611
2640
|
// Aprovacao de ferramenta: navega e confirma.
|
|
2612
2641
|
if (state.approval) {
|
|
2642
|
+
const opts = state.approval.options || APPROVAL_OPTIONS;
|
|
2613
2643
|
if (key.name === "up" || key.name === "down" || key.name === "tab") {
|
|
2614
2644
|
return moveApproval(key.name === "up" ? -1 : 1);
|
|
2615
2645
|
}
|
|
2616
|
-
if (key.name === "return") return settleApproval(
|
|
2646
|
+
if (key.name === "return") return settleApproval(opts[state.approval.index].id);
|
|
2617
2647
|
if (key.name === "escape") return settleApproval("deny");
|
|
2618
2648
|
if (key.name === "a") return settleApproval("always");
|
|
2619
2649
|
if (key.name === "y") return settleApproval("once");
|