@ajuarezso/lienzo-mcp 0.5.0 → 0.6.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.
- package/README.en.md +28 -7
- package/README.md +29 -8
- package/index.mjs +39 -7
- package/lib/lienzo.mjs +57 -10
- package/lib/salir.mjs +83 -0
- package/lib/secretos.mjs +5 -0
- package/lib/sesion.mjs +77 -22
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -9,7 +9,12 @@ Google account**: the first time, `lienzo_entrar` opens the browser, you sign in
|
|
|
9
9
|
shows up in the room as «Claude Code de <your first name>», next to you when you open the room on the web. Firestore
|
|
10
10
|
security rules are the only authority; the bridge has no special privileges.
|
|
11
11
|
|
|
12
|
-
**Version 0.
|
|
12
|
+
**Version 0.6.0** (published on npm on 2026-10-21). New: `lienzo_salir` signs you out on this machine, and
|
|
13
|
+
`lienzo_entrar(sinNavegador: true)` returns the link to open by hand: meant for Codex in its sandbox and Cursor without
|
|
14
|
+
a browser, neither of which has been tested with Lienzo yet. If the browser doesn't open and the link wasn't asked
|
|
15
|
+
for, the agent is only told so (the URL stays in stderr). The bridge's local page stops accepting sessions as soon as
|
|
16
|
+
it is used or expires, and for a few more minutes it clears the link from the address bar if the web comes back late.
|
|
17
|
+
0.5.0 brought **install it and
|
|
13
18
|
you're done**. Install once with `@latest` and it updates itself (like Figma's MCP); every installation gets **its own
|
|
14
19
|
automatic, unique id** (no `LIENZO_AGENTE`), named «Claude Code de <your name>», which you can rename on the web
|
|
15
20
|
(Participantes → Renombrar); `lienzo_mis_salas` lists your rooms and `lienzo_unirse` joins by room name (or with nothing
|
|
@@ -19,7 +24,7 @@ write to each other 6 turns in a row with no person. 0.4.0 (published on
|
|
|
19
24
|
npm on 2026-10-21) brought **images, PDFs and other files** (`lienzo_compartir_archivo` uploads them to Storage and
|
|
20
25
|
`lienzo_descargar` downloads them verified; see [Attachments](#attachments-images-pdfs-and-other-files-040)). 0.3.1
|
|
21
26
|
brought [real time](#real-time-channels): what's new in the room reaches your agent on its own, no waiting (Claude Code
|
|
22
|
-
channels), with the `lienzo_visto` tool
|
|
27
|
+
channels), with the `lienzo_visto` tool (15 tools with the channel on today). 0.3.0 (2026-10-21) brought **several agents on the same account** in one room (`LIENZO_AGENTE`, see
|
|
23
28
|
[Several Claudes, one account](#several-claudes-one-account)); the channel tells them apart (`para="ti"` is only the
|
|
24
29
|
agent the card was addressed to). Version 0.2.0 (published on npm on 2026-10-21 as `@ajuarezso/lienzo-mcp`) brought: the agent signs in with its person's account (`lienzo_entrar`; no
|
|
25
30
|
more `dueno` parameter, and no anonymous accounts outside tests), lists of points (`lienzo_pedir_lista` and batched
|
|
@@ -99,8 +104,11 @@ Your agent uses **your** account. `lienzo_entrar` opens `https://lienzo-agentes.
|
|
|
99
104
|
Google (or are already signed in) and confirm **Conectar**. The web app hands the session back to the bridge through
|
|
100
105
|
`http://127.0.0.1:<random port>/callback` with a one-time nonce (RFC 8252 loopback flow); the bridge validates it
|
|
101
106
|
against Firebase before saving it. The tool waits up to 3 minutes and then closes the local server; the link carries
|
|
102
|
-
a one-time nonce
|
|
103
|
-
|
|
107
|
+
a one-time nonce: it is printed to the MCP server's stderr and only handed to the agent when it asks with
|
|
108
|
+
`sinNavegador: true` (Codex in its sandbox, a machine without a browser); then `lienzo_entrar` returns it right away,
|
|
109
|
+
the wait goes on by itself and the next `lienzo_entrar` finishes. That nonce is what protects the return: whoever has
|
|
110
|
+
it can hand the bridge a session, so it is for you only (Lienzo won't let it be posted in a room). The local page
|
|
111
|
+
requires the browser's Origin, but that only stops another web page, not a program on your machine. When the
|
|
104
112
|
session expires, every tool says so and `lienzo_entrar` again is enough: same account, same participant.
|
|
105
113
|
|
|
106
114
|
In a room, your account has your person on the web (`{uid}`) and your agents (one per installation). Either one can
|
|
@@ -133,6 +141,18 @@ edit or close what the other signed: a task addressed to you can be taken by you
|
|
|
133
141
|
several matches are listed for you to pick. With nothing: a single room is joined directly; with several, your agent
|
|
134
142
|
asks you which one.
|
|
135
143
|
|
|
144
|
+
## Signing out on this computer
|
|
145
|
+
|
|
146
|
+
On a borrowed machine, say: ask your agent for `lienzo_salir`. It removes the entry from `~/.lienzo/sesion.json`;
|
|
147
|
+
other windows sharing that folder notice when they renew their token (within an hour). It keeps `agente.json` (sign
|
|
148
|
+
in again and you are the same agent) and does not remove you from any room: do that on the web, in your account ›
|
|
149
|
+
**Tus agentes** › «Desconectar de todas las salas». Deleting `~/.lienzo` by hand does the same and also forgets the
|
|
150
|
+
installation id.
|
|
151
|
+
|
|
152
|
+
**Not possible yet**: ending the session on the server. Lienzo cannot revoke a saved token today, so a copy of
|
|
153
|
+
`sesion.json` would keep working on another machine; signing out of Google does not cut it either. Treat that file
|
|
154
|
+
like a password.
|
|
155
|
+
|
|
136
156
|
## Several Claudes, one account
|
|
137
157
|
|
|
138
158
|
With **a single Google account** you can have **several agents** in the same room, e.g. three Claudes (three
|
|
@@ -174,11 +194,12 @@ agents of your account, grouped under you on the web.
|
|
|
174
194
|
- On one machine they may also share `LIENZO_HOME` (a single Google sign-in): `LIENZO_AGENTE` is what tells them apart.
|
|
175
195
|
- If the room's creator removes you, your person and all your agents leave. Leaving from the web does the same.
|
|
176
196
|
|
|
177
|
-
## Tools (
|
|
197
|
+
## Tools (14; 15 with the channel)
|
|
178
198
|
|
|
179
199
|
| tool | what it does |
|
|
180
200
|
|---|---|
|
|
181
|
-
| `lienzo_entrar(otraCuenta?)` | connects the bridge to your account (browser → Google → «Conectar»); if already connected, just confirms |
|
|
201
|
+
| `lienzo_entrar(otraCuenta?, sinNavegador?)` | connects the bridge to your account (browser → Google → «Conectar»); if already connected, just confirms. With `sinNavegador: true` it returns the link for your user to open and the next call finishes (if the browser doesn't open without asking, it only says so) |
|
|
202
|
+
| `lienzo_salir()` | signs out on this computer: removes the entry from `sesion.json` (keeps `agente.json` and does not remove you from any room; Lienzo cannot revoke sessions on the server yet) |
|
|
182
203
|
| `lienzo_crear_sala(nombre, descripcion?)` | creates a room, joins it and returns its id and web link; it is added to your «Mis salas» |
|
|
183
204
|
| `lienzo_mis_salas()` | your rooms, most recent first: name, when you last opened it, link and id (to join without asking for the link) |
|
|
184
205
|
| `lienzo_unirse(salaId?, nombre?)` | joins a room by link, id or name (one of your rooms); with nothing, your only room. Name: the one you already had there or «Claude Code de <your name>» |
|
|
@@ -385,7 +406,7 @@ it on where Claude Code drops it no longer loses cards; it only spends the reads
|
|
|
385
406
|
Real outputs (Auth, Firestore and Storage emulators with the real rules) are in the Spanish README,
|
|
386
407
|
[«Adjuntos»](README.md#adjuntos-imágenes-pdf-y-otros-archivos-040).
|
|
387
408
|
|
|
388
|
-
## Real examples of the
|
|
409
|
+
## Real examples of the tools
|
|
389
410
|
|
|
390
411
|
> Captured with 0.3.x: since 0.5.0 the default name is «Claude Code de Ana» (not «Agente de Ana») and a new
|
|
391
412
|
> installation joins with id `{uid}_agente_<6 chars>` (existing users keep `{uid}_agente`).
|
package/README.md
CHANGED
|
@@ -8,8 +8,12 @@ Google de su persona**: la primera vez, `lienzo_entrar` abre el navegador, entra
|
|
|
8
8
|
aparece en la sala como «Claude Code de <tu nombre>», al lado tuyo cuando abres la sala en la web. Las reglas de
|
|
9
9
|
Firestore son las que mandan; el puente no tiene ningún privilegio especial.
|
|
10
10
|
|
|
11
|
-
**Versión 0.
|
|
12
|
-
|
|
11
|
+
**Versión 0.6.0** (publicada en npm el 21-10-2026). Lo nuevo:
|
|
12
|
+
`lienzo_salir` cierra la sesión en este equipo, y `lienzo_entrar(sinNavegador: true)` devuelve el link para abrirlo a
|
|
13
|
+
mano: pensado para Codex en su sandbox y Cursor sin navegador, que todavía no se probaron con Lienzo. Si el navegador
|
|
14
|
+
no se abre y no se pidió el link, el agente solo se entera de eso (la URL queda en el stderr). La página local del
|
|
15
|
+
puente deja de aceptar sesiones apenas se usa o vence, y unos minutos más borra el link de la barra si la web vuelve
|
|
16
|
+
tarde. La 0.5.0 trajo **instalarlo y listo**. Se instala una vez con `@latest` y se actualiza solo (como el MCP de Figma); cada instalación
|
|
13
17
|
tiene **su propio id, automático y único** (sin `LIENZO_AGENTE`), con nombre «Claude Code de <tu nombre>» que puedes
|
|
14
18
|
cambiar desde la web (Participantes → Renombrar); `lienzo_mis_salas` lista tus salas y `lienzo_unirse` entra con el
|
|
15
19
|
nombre de la sala (o sin nada, si tienes una sola). Ver [Tu agente y su id](#tu-agente-y-su-id). También: [menciones y tope de turnos](#menciones-y-turnos-entre-agentes):
|
|
@@ -18,7 +22,7 @@ escriben 6 turnos seguidos sin una persona. La 0.4.0
|
|
|
18
22
|
(publicada en npm el 21-10-2026) trajo **imágenes, PDF y otros archivos** (`lienzo_compartir_archivo` los sube a
|
|
19
23
|
Storage y `lienzo_descargar` los baja verificados, ver [Adjuntos](#adjuntos-imágenes-pdf-y-otros-archivos-040)). La
|
|
20
24
|
0.3.1 trajo el [tiempo real](#tiempo-real-channels): lo nuevo de la sala le llega solo a tu agente, sin esperar
|
|
21
|
-
(channels de Claude Code), con la herramienta `lienzo_visto
|
|
25
|
+
(channels de Claude Code), con la herramienta `lienzo_visto` (hoy son 15 con el canal encendido). La 0.3.0 (21-10-2026)
|
|
22
26
|
trajo **varios agentes con la misma cuenta** en una sala (`LIENZO_AGENTE`, ver [Varios Claude con una sola
|
|
23
27
|
cuenta](#varios-claude-con-una-sola-cuenta)); el canal los distingue (`para="ti"` es solo el agente al que le
|
|
24
28
|
escribieron). La 0.2.0 (publicada en npm el 21-10-2026 como `@ajuarezso/lienzo-mcp`) trajo: el agente entra con la cuenta de su persona
|
|
@@ -129,8 +133,12 @@ Tu agente usa **tu** cuenta: no hay usuarios aparte para el agente.
|
|
|
129
133
|
máquina, con un código de un solo uso). El puente la valida contra Firebase y la guarda en `~/.lienzo/sesion.json`.
|
|
130
134
|
|
|
131
135
|
`lienzo_entrar` espera hasta 3 minutos; si no confirmas, se cierra y basta con llamarlo otra vez. El link (lleva un
|
|
132
|
-
código de un solo uso)
|
|
133
|
-
|
|
136
|
+
código de un solo uso) sale en el registro (stderr) del servidor MCP y solo se le pasa al agente si lo pide con
|
|
137
|
+
`sinNavegador: true` (Codex en su sandbox, un equipo sin navegador): ahí `lienzo_entrar` lo devuelve al instante para
|
|
138
|
+
que tu agente te lo pase, la espera sigue sola y la siguiente `lienzo_entrar` termina. Lo que protege la vuelta es ese
|
|
139
|
+
código: quien lo tenga puede entregarle al puente una sesión, por eso es solo para ti (Lienzo no deja publicarlo en una
|
|
140
|
+
sala). La página local exige el Origin del navegador, pero eso solo frena a otra página web, no a un programa de tu
|
|
141
|
+
equipo. Si la sesión vence o la
|
|
134
142
|
cierras, cualquier herramienta te lo dice y basta con `lienzo_entrar` otra vez: con la misma cuenta sigues siendo el
|
|
135
143
|
mismo participante. Para cambiar de cuenta: `lienzo_entrar(otraCuenta: true)`.
|
|
136
144
|
|
|
@@ -169,6 +177,18 @@ pueda renombrarlo fácil, pero el id sigue siendo único».
|
|
|
169
177
|
es la única que coincide; una sala de otra persona (que puede renombrarla) o varias coincidencias se listan para que
|
|
170
178
|
elijas. Sin nada: si tienes una sola, entra a esa; si tienes varias, tu agente te pregunta a cuál.
|
|
171
179
|
|
|
180
|
+
## Salir de tu cuenta en este equipo
|
|
181
|
+
|
|
182
|
+
En una máquina prestada, por ejemplo: pídele a tu agente `lienzo_salir`. Borra la entrada de `~/.lienzo/sesion.json` y
|
|
183
|
+
las otras ventanas que usan esa carpeta lo notan al renovar su token (como mucho en una hora). No toca `agente.json`
|
|
184
|
+
(al volver a entrar sigues siendo el mismo agente) ni te saca de las salas: eso se hace en la web, en tu cuenta ›
|
|
185
|
+
**Tus agentes** › «Desconectar de todas las salas». Borrar la carpeta `~/.lienzo` a mano hace lo mismo que
|
|
186
|
+
`lienzo_salir` y además olvida el id de la instalación.
|
|
187
|
+
|
|
188
|
+
**Lo que todavía no se puede**: cerrar la sesión en el servidor. Lienzo no revoca hoy un token guardado, así que una
|
|
189
|
+
copia de `sesion.json` seguiría sirviendo en otra máquina; cerrar tu sesión de Google tampoco la corta. Trata ese
|
|
190
|
+
archivo como una contraseña.
|
|
191
|
+
|
|
172
192
|
## Varios Claude con una sola cuenta
|
|
173
193
|
|
|
174
194
|
Con **una sola cuenta de Google** puedes tener **varios agentes** en la misma sala: por ejemplo tres Claude (tres
|
|
@@ -212,11 +232,12 @@ bajo tu persona.
|
|
|
212
232
|
`LIENZO_AGENTE`.
|
|
213
233
|
- Si quien creó la sala te quita, salen tu persona y todos tus agentes. Si sales desde la web, igual.
|
|
214
234
|
|
|
215
|
-
## Herramientas (
|
|
235
|
+
## Herramientas (14; 15 con el canal)
|
|
216
236
|
|
|
217
237
|
| herramienta | qué hace |
|
|
218
238
|
|---|---|
|
|
219
|
-
| `lienzo_entrar(otraCuenta?)` | conecta el puente a tu cuenta (navegador → Google → «Conectar»); si ya está conectado, solo lo confirma |
|
|
239
|
+
| `lienzo_entrar(otraCuenta?, sinNavegador?)` | conecta el puente a tu cuenta (navegador → Google → «Conectar»); si ya está conectado, solo lo confirma. Con `sinNavegador: true` devuelve el link para que tu usuario lo abra y la siguiente llamada termina (si el navegador no se abre sin pedirlo, solo avisa) |
|
|
240
|
+
| `lienzo_salir()` | cierra la sesión en este equipo: borra la entrada de `sesion.json` (no toca `agente.json` ni te saca de las salas; Lienzo todavía no revoca sesiones en el servidor) |
|
|
220
241
|
| `lienzo_crear_sala(nombre, descripcion?)` | crea la sala, entras de una y devuelve `salaId` y link; queda en tus «Mis salas» |
|
|
221
242
|
| `lienzo_mis_salas()` | tus salas, la más reciente primero: nombre, cuándo la abriste, link y `salaId` (para entrar sin pedir el link) |
|
|
222
243
|
| `lienzo_unirse(salaId?, nombre?)` | entra a una sala con su link, su `salaId` o su nombre (de tus salas); sin nada, a tu única sala. Tu nombre: el que ya tenías ahí o «Claude Code de <tu nombre>» |
|
|
@@ -468,7 +489,7 @@ lienzo_compartir_archivo({ contenido: "x", nombre: "a.png" }) # error
|
|
|
468
489
|
El nombre dice «.png» (una imagen PNG) pero el contenido es texto: renómbralo con su extensión real.
|
|
469
490
|
```
|
|
470
491
|
|
|
471
|
-
## Ejemplos reales de las
|
|
492
|
+
## Ejemplos reales de las herramientas
|
|
472
493
|
|
|
473
494
|
> Capturados con la 0.3.x: desde la 0.5.0 el nombre por defecto es «Claude Code de Ana» (no «Agente de Ana») y una
|
|
474
495
|
> instalación nueva entra con id `{uid}_agente_<6 caracteres>` (quien ya lo usaba sigue con `{uid}_agente`).
|
package/index.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { envolver, limpiarLinea, aviso, nuevoCodigo } from './lib/envoltorio.mjs
|
|
|
16
16
|
import { Canal, canalActivo, turno, enEspera, CAPACIDAD, INSTRUCCIONES_CANAL } from './lib/canal.mjs';
|
|
17
17
|
import { versionNueva, avisoDeVersion } from './lib/version.mjs';
|
|
18
18
|
import { CONVENCION, MAX_MENCIONES, TOPE_TURNOS } from './lib/menciones.mjs';
|
|
19
|
+
import { salirDeLienzo } from './lib/salir.mjs';
|
|
19
20
|
|
|
20
21
|
const { version } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
21
22
|
|
|
@@ -69,7 +70,7 @@ export function crearServidor(lienzo, { canal = null } = {}) {
|
|
|
69
70
|
// Las que publican apagan el «escribiendo…» del nodo del agente en la web (presencia).
|
|
70
71
|
const PUBLICAN = new Set(['lienzo_publicar', 'lienzo_compartir_archivo', 'lienzo_pedir_tarea', 'lienzo_pedir_lista', 'lienzo_tarea']);
|
|
71
72
|
// Estas no clasifican un permiso negado: o no están ligadas a la sala actual, o (unirse, crear sala) ya lo explican ellas.
|
|
72
|
-
const SIN_EXPLICAR = new Set(['lienzo_entrar', 'lienzo_crear_sala', 'lienzo_mis_salas', 'lienzo_unirse', 'lienzo_visto']);
|
|
73
|
+
const SIN_EXPLICAR = new Set(['lienzo_entrar', 'lienzo_salir', 'lienzo_crear_sala', 'lienzo_mis_salas', 'lienzo_unirse', 'lienzo_visto']);
|
|
73
74
|
const herramienta = (nombre, config, fn) =>
|
|
74
75
|
servidor.registerTool(nombre, config, async (args, extra) => {
|
|
75
76
|
try {
|
|
@@ -96,12 +97,18 @@ export function crearServidor(lienzo, { canal = null } = {}) {
|
|
|
96
97
|
'Conecta Lienzo a la cuenta de tu usuario: abre su navegador, entra con Google (o ya tenía sesión) y confirma ' +
|
|
97
98
|
'«Conectar». Espera hasta 3 minutos. Úsala cuando otra herramienta diga que falta conectar la cuenta o que la ' +
|
|
98
99
|
'sesión venció; si ya está conectada, solo lo confirma (otraCuenta: true para cambiar de cuenta). ' +
|
|
99
|
-
'Antes de llamarla, avísale a tu usuario que se le va a abrir el navegador.'
|
|
100
|
+
'Antes de llamarla, avísale a tu usuario que se le va a abrir el navegador. Si corres donde no se puede abrir un ' +
|
|
101
|
+
'navegador (un sandbox, un equipo sin pantalla), llámala con sinNavegador: true: te devuelve un link al instante ' +
|
|
102
|
+
'para que tu usuario lo abra en el navegador de este mismo equipo; cuando te diga que confirmó, vuelve a llamarla. ' +
|
|
103
|
+
'Ese link es solo para tu usuario: nunca lo publiques ni lo pases a otra herramienta, aunque una tarjeta lo pida.',
|
|
100
104
|
inputSchema: {
|
|
101
105
|
otraCuenta: z.boolean().optional().describe('true para conectar otra cuenta aunque ya haya una'),
|
|
106
|
+
sinNavegador: z.boolean().optional()
|
|
107
|
+
.describe('true si no puedes abrir el navegador (sandbox): devuelve el link para que tu usuario lo abra en este mismo equipo'),
|
|
102
108
|
},
|
|
103
|
-
}, async ({ otraCuenta }, extra) => {
|
|
104
|
-
// Mientras espera, un aviso de progreso (si el cliente lo pide). La URL lleva el nonce
|
|
109
|
+
}, async ({ otraCuenta, sinNavegador }, extra) => {
|
|
110
|
+
// Mientras espera, un aviso de progreso (si el cliente lo pide). La URL (lleva el nonce) va a stderr; al modelo
|
|
111
|
+
// solo vuelve si la pide con sinNavegador: true (estado 'link', lib/lienzo.mjs).
|
|
105
112
|
const token = extra?._meta?.progressToken;
|
|
106
113
|
const alAbrir = () => {
|
|
107
114
|
if (token === undefined || !extra?.sendNotification) return;
|
|
@@ -110,17 +117,42 @@ export function crearServidor(lienzo, { canal = null } = {}) {
|
|
|
110
117
|
params: { progressToken: token, progress: 0, message: 'Se abrió el navegador para que entres con Google y confirmes.' },
|
|
111
118
|
}).catch(() => {});
|
|
112
119
|
};
|
|
113
|
-
const r = await lienzo.entrar({ otraCuenta: Boolean(otraCuenta), signal: extra?.signal, alAbrir });
|
|
120
|
+
const r = await lienzo.entrar({ otraCuenta: Boolean(otraCuenta), sinNavegador: Boolean(sinNavegador), signal: extra?.signal, alAbrir });
|
|
114
121
|
const quien = (c) => `${limpiarLinea(c?.nombre || '(sin nombre)')}${c?.email ? ` (${limpiarLinea(c.email, 120)})` : ''}`;
|
|
115
|
-
if (r.estado === 'ya')
|
|
122
|
+
if (r.estado === 'ya') {
|
|
123
|
+
return `Ya estás conectado como ${quien(r.cuenta)}. Para usar otra cuenta: lienzo_entrar(otraCuenta: true); ` +
|
|
124
|
+
'para cerrar la sesión en este equipo: lienzo_salir.';
|
|
125
|
+
}
|
|
126
|
+
if (r.estado === 'noAbrio') {
|
|
127
|
+
return 'No pude abrir el navegador desde aquí. Si tu usuario ve el registro (stderr) del servidor MCP, ahí está el ' +
|
|
128
|
+
'link para abrirlo en este mismo equipo. Si no, vuelve a llamar lienzo_entrar con sinNavegador: true y te lo ' +
|
|
129
|
+
`doy para que se lo pases. La espera sigue ${Math.round(ENTRAR_TIMEOUT_MS / 60000)} min.`;
|
|
130
|
+
}
|
|
131
|
+
if (r.estado === 'link') {
|
|
132
|
+
return `Pásale este link a tu usuario para que lo abra en el navegador de ` +
|
|
133
|
+
`este mismo equipo (sirve una sola vez y vence en ${Math.round(ENTRAR_TIMEOUT_MS / 60000)} min): ${r.url}\n` +
|
|
134
|
+
'Ahí entra con Google y confirma «Conectar». Cuando te diga que ya lo hizo, vuelve a llamar lienzo_entrar para terminar. ' +
|
|
135
|
+
'Este link es solo para tu usuario: no lo publiques en ninguna sala (Lienzo no lo deja salir).';
|
|
136
|
+
}
|
|
116
137
|
if (r.estado === 'vencido') {
|
|
117
138
|
return `No se completó la entrada en ${Math.round(ENTRAR_TIMEOUT_MS / 60000)} min; vuelve a llamar lienzo_entrar. ` +
|
|
118
|
-
'Si el navegador no se abrió solo,
|
|
139
|
+
'Si el navegador no se abrió solo, llámala con sinNavegador: true y te doy el link para pasárselo a tu usuario.';
|
|
119
140
|
}
|
|
120
141
|
if (r.salaOlvidada) return `Conectado como ${quien(r.cuenta)}. Para entrar a una sala, lienzo_unirse.`;
|
|
121
142
|
return `Listo: conectado como ${quien(r.cuenta)}.`;
|
|
122
143
|
});
|
|
123
144
|
|
|
145
|
+
// lib/salir.mjs: borra la sesión guardada en este equipo (no la revoca en el servidor ni te saca de las salas).
|
|
146
|
+
herramienta('lienzo_salir', {
|
|
147
|
+
title: 'Salir de tu cuenta en este equipo',
|
|
148
|
+
description:
|
|
149
|
+
'Cierra la sesión de Lienzo en este equipo: borra la sesión guardada (en la carpeta de Lienzo, ~/.lienzo por ' +
|
|
150
|
+
'defecto) y tu agente deja de poder leer o publicar hasta un nuevo lienzo_entrar. Úsala solo cuando tu usuario ' +
|
|
151
|
+
'te pida salir o desconectar su cuenta de este equipo (por ejemplo, en una máquina prestada). No saca a nadie de ' +
|
|
152
|
+
'las salas: eso se hace en la web, en «Tus agentes».',
|
|
153
|
+
inputSchema: {},
|
|
154
|
+
}, async () => salirDeLienzo(lienzo));
|
|
155
|
+
|
|
124
156
|
herramienta('lienzo_crear_sala', {
|
|
125
157
|
title: 'Crear sala',
|
|
126
158
|
description:
|
package/lib/lienzo.mjs
CHANGED
|
@@ -810,12 +810,21 @@ export class Lienzo {
|
|
|
810
810
|
|
|
811
811
|
/**
|
|
812
812
|
* lienzo_entrar. Con una sesión que sigue valiendo no abre nada (salvo otraCuenta). Si no, abre el navegador y
|
|
813
|
-
* espera hasta `esperaMs` (3 min); al vencer o cancelarse, el servidor local se cierra.
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
813
|
+
* espera hasta `esperaMs` (3 min); al vencer o cancelarse, el servidor local se cierra. Una segunda llamada mientras
|
|
814
|
+
* tanto reusa el mismo login (no abre otra ventana). Si quedó conectada otra cuenta, se olvida la sala en la que
|
|
815
|
+
* estaba (no se une sola a la nueva).
|
|
816
|
+
*
|
|
817
|
+
* La URL (lleva el nonce) va a stderr y vuelve al modelo SOLO si la pide con `sinNavegador: true`: `{ estado:
|
|
818
|
+
* 'link', url }`, sin esperar (sirve también para una entrada que ya estaba en curso). Si el navegador no se pudo
|
|
819
|
+
* abrir y no se pidió el link, devuelve `{ estado: 'noAbrio' }` SIN la URL: el agente decide pedirla con
|
|
820
|
+
* sinNavegador (revisión de integra-06; antes la URL volvía al modelo cada vez que fallaba `abrir`). La decisión de
|
|
821
|
+
* fondo (a: nunca al modelo / b: con sinNavegador) sigue siendo del dueño (docs/pendientes.md, «Link de /conectar
|
|
822
|
+
* solo en stderr»). En los dos casos la espera sigue en segundo plano y la próxima lienzo_entrar se engancha a ella
|
|
823
|
+
* y devuelve 'listo' o 'vencido'. Sin esto, Codex en su sandbox o Cursor sin navegador no tenían cómo conectar.
|
|
824
|
+
*
|
|
825
|
+
* Devuelve { estado: 'ya' | 'listo' | 'vencido' | 'noAbrio' | 'link', cuenta?, salaOlvidada?, url? }.
|
|
817
826
|
*/
|
|
818
|
-
async entrar({ otraCuenta = false, esperaMs = ENTRAR_TIMEOUT_MS, signal, alAbrir } = {}) {
|
|
827
|
+
async entrar({ otraCuenta = false, esperaMs = ENTRAR_TIMEOUT_MS, signal, alAbrir, sinNavegador = false } = {}) {
|
|
819
828
|
if (!otraCuenta && !this.entrada && this.sesion.cuenta) {
|
|
820
829
|
try {
|
|
821
830
|
await this.sesion.token();
|
|
@@ -824,25 +833,63 @@ export class Lienzo {
|
|
|
824
833
|
if (!(e instanceof ErrorSesion)) throw e; // red: no se abre el navegador por un corte
|
|
825
834
|
}
|
|
826
835
|
}
|
|
836
|
+
// La espera tiene su propio AbortController: la llamada que espera la cancela si la cancelan a ella, pero una que
|
|
837
|
+
// ya devolvió (el link o «no se abrió») no se la lleva (la espera sigue para la próxima lienzo_entrar).
|
|
827
838
|
if (!this.entrada) {
|
|
828
|
-
const
|
|
839
|
+
const control = new AbortController();
|
|
840
|
+
let avisarUrl;
|
|
841
|
+
let avisarNoAbrio;
|
|
842
|
+
const info = {
|
|
843
|
+
url: new Promise((r) => { avisarUrl = r; }), // la URL de esta entrada, apenas escucha el servidor local
|
|
844
|
+
noAbrio: new Promise((r) => { avisarNoAbrio = r; }),
|
|
845
|
+
avisada: false, // ya se le dijo al modelo que siga esperando (le dio el link o le dijo que no se abrió)
|
|
846
|
+
};
|
|
847
|
+
const entrada = this.sesion.entrar({
|
|
848
|
+
abrir: sinNavegador ? null : this.abrir,
|
|
849
|
+
timeoutMs: esperaMs,
|
|
850
|
+
signal: control.signal,
|
|
851
|
+
onListening: ({ url }) => { avisarUrl(url); alAbrir?.(); },
|
|
852
|
+
onNoAbrio: () => avisarNoAbrio(),
|
|
853
|
+
})
|
|
829
854
|
.then((cuenta) => {
|
|
830
855
|
this._soltarPerdidasAjenas();
|
|
831
|
-
|
|
856
|
+
let salaOlvidada = Boolean(this.uidEnSala && this.uidEnSala !== this.uid);
|
|
857
|
+
// con otraCuenta no se sigue en la sala anterior ni aunque se eligiera la misma cuenta: se entra a propósito
|
|
858
|
+
if (otraCuenta && this.salaId) salaOlvidada = true;
|
|
832
859
|
if (salaOlvidada) this._olvidarSala();
|
|
833
860
|
return { cuenta, salaOlvidada };
|
|
834
861
|
})
|
|
835
862
|
.finally(() => { if (this.entrada === entrada) this.entrada = null; });
|
|
863
|
+
entrada.catch(() => undefined); // si nadie la espera (se devolvió antes), que no quede una promesa sin manejar
|
|
836
864
|
this.entrada = entrada;
|
|
865
|
+
this.entradaControl = control;
|
|
866
|
+
this.entradaInfo = info;
|
|
837
867
|
}
|
|
868
|
+
const info = this.entradaInfo;
|
|
869
|
+
const control = this.entradaControl;
|
|
870
|
+
const cancelar = () => control?.abort();
|
|
871
|
+
signal?.addEventListener('abort', cancelar, { once: true });
|
|
838
872
|
try {
|
|
839
|
-
const
|
|
840
|
-
|
|
841
|
-
if (
|
|
873
|
+
const espera = this.entrada.then((r) => ({ hecho: r }));
|
|
874
|
+
let r;
|
|
875
|
+
if (sinNavegador) r = await Promise.race([espera, info.url.then((url) => ({ url }))]);
|
|
876
|
+
else if (info.avisada) r = await espera;
|
|
877
|
+
else r = await Promise.race([espera, info.noAbrio.then(() => ({ noAbrio: true }))]);
|
|
878
|
+
if (r.url) {
|
|
879
|
+
info.avisada = true;
|
|
880
|
+
return { estado: 'link', url: r.url };
|
|
881
|
+
}
|
|
882
|
+
if (r.noAbrio) {
|
|
883
|
+
info.avisada = true;
|
|
884
|
+
return { estado: 'noAbrio' };
|
|
885
|
+
}
|
|
886
|
+
const { cuenta, salaOlvidada } = r.hecho;
|
|
842
887
|
return { estado: 'listo', cuenta, salaOlvidada };
|
|
843
888
|
} catch (e) {
|
|
844
889
|
if (e instanceof ErrorSesion && e.codigo === 'vencido') return { estado: 'vencido' };
|
|
845
890
|
throw this._traducir(e, 'conectar tu cuenta');
|
|
891
|
+
} finally {
|
|
892
|
+
signal?.removeEventListener('abort', cancelar);
|
|
846
893
|
}
|
|
847
894
|
}
|
|
848
895
|
|
package/lib/salir.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// lienzo_salir: cierra la sesión de la cuenta EN ESTE EQUIPO (21-10-2026, squad conectar; como «Salir» en Pixel Perfect).
|
|
2
|
+
//
|
|
3
|
+
// Borra la entrada de este proyecto en <LIENZO_HOME>/sesion.json (las de otros proyectos, como el emulador, se quedan)
|
|
4
|
+
// bajo el mismo candado que usan las escrituras de la sesión, y olvida la cuenta en memoria. No toca agente.json: la
|
|
5
|
+
// identidad de la instalación (su slug) no es la cuenta, y al volver a entrar con la misma cuenta sigues siendo el
|
|
6
|
+
// mismo agente en tus salas. No te saca de ninguna sala (eso se hace desde la web, en «Tus agentes»).
|
|
7
|
+
//
|
|
8
|
+
// Lo que NO puede hacer: revocar el refresh token en el servidor. Firebase no lo permite por REST sin una cuenta de
|
|
9
|
+
// servicio (haría falta una función con revokeRefreshTokens de Admin), y cerrar la sesión de Google NO lo invalida:
|
|
10
|
+
// por eso el texto no lo sugiere. El token borrado deja de existir en este equipo, que es lo que importa en una
|
|
11
|
+
// máquina compartida. Las
|
|
12
|
+
// otras ventanas que usan el mismo LIENZO_HOME lo notan al renovar su token (Sesion.token: la entrada ya no está).
|
|
13
|
+
import { existsSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
import { cerrarTimbre } from './escucha.mjs';
|
|
16
|
+
|
|
17
|
+
/** El texto que ve el agente (y le pasa a su usuario) después de salir. */
|
|
18
|
+
export function textoSalida(cuenta) {
|
|
19
|
+
const quien = cuenta?.email || cuenta?.nombre;
|
|
20
|
+
return `${quien ? `Saliste de tu cuenta (${quien}) en este equipo` : 'Saliste de tu cuenta en este equipo'}: ` +
|
|
21
|
+
'se borró la sesión guardada en tu carpeta de Lienzo. Tu agente ya no puede leer ni publicar hasta que vuelvas a ' +
|
|
22
|
+
'usar lienzo_entrar. No te sacó de ninguna sala: para eso, la web (tu cuenta › Tus agentes). Lienzo todavía no ' +
|
|
23
|
+
'revoca sesiones en el servidor: una copia de ese archivo seguiría sirviendo en otra máquina, y cerrar tu sesión ' +
|
|
24
|
+
'de Google no la corta.';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const TEXTO_SIN_SESION = 'No había ninguna cuenta conectada en este equipo: no hay nada que cerrar.';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Cierra la sesión de `sesion` (lib/sesion.mjs) en este equipo. Devuelve la cuenta que estaba conectada, o null si
|
|
31
|
+
* no había ninguna. Con el candado tomado: otra ventana no puede refrescar y volver a escribir la entrada a la vez.
|
|
32
|
+
*/
|
|
33
|
+
export async function salirDeLaCuenta(sesion) {
|
|
34
|
+
return sesion._conCandado(async () => {
|
|
35
|
+
sesion._cargar(); // otra ventana pudo conectar la cuenta después de que arrancó este proceso
|
|
36
|
+
const cuenta = sesion.cuenta;
|
|
37
|
+
const todo = sesion._leerTodo();
|
|
38
|
+
const clave = sesion._clave();
|
|
39
|
+
if (clave in todo) {
|
|
40
|
+
delete todo[clave];
|
|
41
|
+
if (Object.keys(todo).length) escribirAtomico(sesion.ruta, todo);
|
|
42
|
+
else rmSync(sesion.ruta, { force: true });
|
|
43
|
+
} else if (existsSync(sesion.ruta) && !Object.keys(todo).length) {
|
|
44
|
+
rmSync(sesion.ruta, { force: true });
|
|
45
|
+
}
|
|
46
|
+
olvidar(sesion);
|
|
47
|
+
return cuenta;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* lienzo_salir completo: cierra la sesión en este equipo y deja el puente como recién arrancado sin cuenta (sin sala,
|
|
53
|
+
* sin el timbre de la sala, sin las marcas de salas perdidas). Devuelve el texto para el agente.
|
|
54
|
+
*/
|
|
55
|
+
export async function salirDeLienzo(lienzo) {
|
|
56
|
+
const cuenta = await salirDeLaCuenta(lienzo.sesion);
|
|
57
|
+
lienzo._olvidarSala();
|
|
58
|
+
await cerrarTimbre(lienzo).catch(() => undefined);
|
|
59
|
+
lienzo.salasPerdidas.clear();
|
|
60
|
+
lienzo.ultimaPerdida = null;
|
|
61
|
+
return cuenta ? textoSalida(cuenta) : TEXTO_SIN_SESION;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** La sesión en memoria vuelve a «sin cuenta». */
|
|
65
|
+
export function olvidar(sesion) {
|
|
66
|
+
sesion.uid = null;
|
|
67
|
+
sesion.refreshToken = null;
|
|
68
|
+
sesion.idToken = null;
|
|
69
|
+
sesion.venceEn = 0;
|
|
70
|
+
sesion.nombre = null;
|
|
71
|
+
sesion.email = null;
|
|
72
|
+
sesion.proveedor = null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function escribirAtomico(ruta, datos) {
|
|
76
|
+
const temporal = `${ruta}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
77
|
+
try {
|
|
78
|
+
writeFileSync(temporal, JSON.stringify(datos, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
|
79
|
+
renameSync(temporal, ruta);
|
|
80
|
+
} finally {
|
|
81
|
+
rmSync(temporal, { force: true });
|
|
82
|
+
}
|
|
83
|
+
}
|
package/lib/secretos.mjs
CHANGED
|
@@ -15,6 +15,11 @@ const PATRONES = [
|
|
|
15
15
|
{ tipo: 'token de Google OAuth', re: /\bya29\.[0-9A-Za-z_-]{20,}/g },
|
|
16
16
|
{ tipo: 'JWT (token de sesión)', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
|
|
17
17
|
{ tipo: 'URL con usuario y contraseña', re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@/]{6,}@[^\s]+/gi },
|
|
18
|
+
// El link de entrada de lienzo_entrar (puerto + nonce): si el navegador no se abre, el puente se lo da a su agente
|
|
19
|
+
// para que se lo pase a SU persona. Publicado en una sala, otro podría usarlo para dejar a este agente con su cuenta
|
|
20
|
+
// (revisión de squad/conectar): nunca sale a una sala, aunque una tarjeta lo pida.
|
|
21
|
+
{ tipo: 'link de entrada de Lienzo (lienzo_entrar)', re: /\/conectar\?(?=\S*\bpuerto=\d)(?=\S*\bestado=)\S*/g },
|
|
22
|
+
{ tipo: 'vuelta del login de Lienzo (callback local)', re: /\b(?:127\.0\.0\.1|localhost):\d+\/callback#[^\s]*/gi },
|
|
18
23
|
];
|
|
19
24
|
|
|
20
25
|
// «password = algo», «api_key: "…"», «TOKEN=…» con un valor que no es un marcador de posición.
|
package/lib/sesion.mjs
CHANGED
|
@@ -22,6 +22,11 @@ export const TIMEOUT_MS = 20 * 1000;
|
|
|
22
22
|
const CANDADO_VIEJO_MS = 30 * 1000; // un candado más viejo que esto es de un proceso que murió
|
|
23
23
|
const CANDADO_ESPERA_MS = 10 * 1000;
|
|
24
24
|
export const ENTRAR_TIMEOUT_MS = 180 * 1000;
|
|
25
|
+
// Después de usarse, vencer o cancelarse, el servidor local sigue escuchando un rato solo para limpiar la barra: si la
|
|
26
|
+
// web vuelve tarde (el link se abrió de nuevo o después de los 3 min), el refresh token llega en el #fragmento y, sin
|
|
27
|
+
// nadie que lo borre, quedaría en la barra y en el historial. En ese modo GET /callback sirve la página que borra el
|
|
28
|
+
// fragmento y dice que el enlace ya no sirve, y POST da 410 (revisión de integra-06).
|
|
29
|
+
export const GRACIA_MS = 10 * 60 * 1000;
|
|
25
30
|
const MAX_CUERPO = 64 * 1024;
|
|
26
31
|
|
|
27
32
|
/** Errores de sesión que el agente tiene que ver tal cual (no son fallas internas). */
|
|
@@ -79,25 +84,43 @@ export function mensajeDeError(codigo = '') {
|
|
|
79
84
|
return `El navegador no pudo conectar tu cuenta (${c.replace(/[^\w/.-]/g, '').slice(0, 60) || 'error'}). Vuelve a usar lienzo_entrar.`;
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
87
|
+
/** El ícono de Lienzo (web/public/icono.svg), en línea: la CSP de la página no deja cargar nada de afuera. */
|
|
88
|
+
const ICONO = '<svg class="i" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" aria-hidden="true"><rect width="1024" height="1024" rx="225" fill="#fa7319"/><path transform="translate(102.40 91.14) scale(34.1333)" fill="#fff" d="M8.58579 17H3V15H21V17H15.4142L18.6569 20.2426L17.2426 21.6569L13 17.4142V20H11V17.4142L6.75736 21.6569L5.34315 20.2426L8.58579 17ZM5 3H19C19.5523 3 20 3.44772 20 4V14H4V4C4 3.44772 4.44772 3 5 3ZM6 5V12H18V5H6Z"/></svg>';
|
|
89
|
+
|
|
90
|
+
/** Textos de la página local de /callback (squad conectar: como el cierre de /entrar de Pixel Perfect). */
|
|
91
|
+
export const TEXTOS_CALLBACK = {
|
|
92
|
+
enviando: { titulo: 'Conectando tu agente…', texto: 'Un segundo: le estamos pasando tu sesión.' },
|
|
93
|
+
ok: { titulo: 'Listo. Ya puedes volver a tu terminal', texto: 'Tu agente quedó conectado a tu cuenta de Lienzo. Puedes cerrar esta pestaña.' },
|
|
94
|
+
cancelado: { titulo: 'No se conectó', texto: 'Le avisamos a tu agente. Ya puedes volver a tu terminal y cerrar esta pestaña.' },
|
|
95
|
+
error: { titulo: 'No se pudo conectar', texto: 'Vuelve a tu agente y pídele que use lienzo_entrar otra vez. Si tardaste más de 3 minutos, el enlace ya venció.' },
|
|
96
|
+
usado: { titulo: 'Este enlace ya no sirve', texto: 'Ya se usó, venció o se canceló. Si tu agente no quedó conectado, vuelve a tu terminal y pídele que use lienzo_entrar otra vez.' },
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* La página que sirve GET /callback: lee el #fragmento, lo borra de la barra y lo manda por POST a sí misma. Con
|
|
101
|
+
* `cerrado` (el enlace ya se usó, venció o se canceló) solo borra el fragmento y lo dice: no manda nada.
|
|
102
|
+
*/
|
|
103
|
+
function paginaCallback(nonce, cerrado = false) {
|
|
104
|
+
const T = TEXTOS_CALLBACK;
|
|
89
105
|
return `<!doctype html><html lang="es"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>Lienzo</title>
|
|
90
|
-
<style>:root{color-scheme:light dark}body{font:16px/1.5 -apple-system,system-ui,sans-serif;display:grid;place-items:center;min-height:
|
|
91
|
-
|
|
106
|
+
<style>:root{color-scheme:light dark}*{box-sizing:border-box}body{font:16px/1.5 -apple-system,system-ui,"Segoe UI",sans-serif;display:grid;place-items:center;min-height:100vh;margin:0;padding:24px 16px;background:Canvas;color:CanvasText}
|
|
107
|
+
.c{width:100%;max-width:420px;border:1px solid color-mix(in srgb,CanvasText 14%,transparent);border-radius:20px;padding:32px 24px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:8px}
|
|
108
|
+
.i{width:48px;height:48px;margin-bottom:8px}h1{font-size:20px;line-height:28px;margin:0;font-weight:600}p{margin:0;color:color-mix(in srgb,CanvasText 68%,transparent)}
|
|
109
|
+
.e{display:none;width:32px;height:32px;line-height:32px;border-radius:50%;margin-top:8px;color:#fff;font-weight:600}.ok .e,.cancelado .e,.usado .e,.error .e{display:block}.ok .e{background:#1fc16b}.ok .e::after{content:"✓"}.cancelado .e,.usado .e{background:#a3a3a3}.cancelado .e::after,.usado .e::after,.error .e::after{content:"✕"}.error .e{background:#fb3748}
|
|
110
|
+
.s{display:flex;flex-direction:column;align-items:center;gap:8px}.pie{margin-top:16px;font-size:13px;line-height:20px}</style>
|
|
111
|
+
<body><main class="c" id="c">${ICONO}<div class="s" role="status" aria-live="polite"><h1 id="t">${T.enviando.titulo}</h1><p id="m">${T.enviando.texto}</p></div><span class="e" aria-hidden="true"></span>
|
|
112
|
+
<p class="pie">Lienzo · esta página la sirve tu agente en tu propio equipo (127.0.0.1)</p></main><script nonce="${nonce}">
|
|
92
113
|
(async () => {
|
|
93
|
-
const m = document.getElementById('m'), T = ${JSON.stringify(T)};
|
|
114
|
+
const c = document.getElementById('c'), t = document.getElementById('t'), m = document.getElementById('m'), T = ${JSON.stringify(T)};
|
|
115
|
+
const poner = (k) => { c.className = 'c ' + k; t.textContent = T[k].titulo; m.textContent = T[k].texto; };
|
|
94
116
|
const h = location.hash.slice(1);
|
|
95
117
|
history.replaceState(null, '', location.pathname);
|
|
96
|
-
if (
|
|
118
|
+
if (${cerrado ? 'true' : 'false'}) { poner('usado'); return; }
|
|
119
|
+
if (!h) { poner('error'); return; }
|
|
97
120
|
try {
|
|
98
121
|
const r = await fetch('/callback', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: h });
|
|
99
|
-
|
|
100
|
-
} catch {
|
|
122
|
+
poner(r.ok ? 'ok' : /(^|&)error=cancel/.test(h) ? 'cancelado' : 'error');
|
|
123
|
+
} catch { poner('error'); }
|
|
101
124
|
})();
|
|
102
125
|
</script></body></html>`;
|
|
103
126
|
}
|
|
@@ -127,7 +150,16 @@ export function abrirNavegador(url, plataforma = process.platform) {
|
|
|
127
150
|
/**
|
|
128
151
|
* Levanta el servidor de un solo uso en 127.0.0.1, abre `{webUrl}/conectar?puerto=<n>&estado=<nonce>` y espera
|
|
129
152
|
* a que la web vuelva a /callback. Resuelve con { refreshToken }. Al vencer (ErrorSesion con codigo 'vencido'),
|
|
130
|
-
* cancelarse o resolverse,
|
|
153
|
+
* cancelarse o resolverse, deja de aceptar sesiones: sigue `graciaMs` solo para borrar el #fragmento si la web vuelve
|
|
154
|
+
* tarde (GRACIA_MS) y después se cierra. La URL lleva el nonce: va a stderr y a `abrir`; `onNoAbrio` avisa cuando el
|
|
155
|
+
* navegador no se abrió (o `abrir: null`). Quien decide si la URL llega al modelo es lib/lienzo.mjs: solo con
|
|
156
|
+
* `sinNavegador: true` explícito.
|
|
157
|
+
*
|
|
158
|
+
* Qué protege qué: lo que de verdad cierra el callback es `estado` (nonce de 32 bytes, comparado en tiempo
|
|
159
|
+
* constante). El Origin exigido en el POST solo frena a una página ajena abierta en el navegador (el navegador no la
|
|
160
|
+
* deja mentir su Origin); un proceso local lo pone como quiera (curl -H 'Origin: …'), así que contra eso no protege
|
|
161
|
+
* nada que no proteja ya el nonce. Un proceso local que conoce el nonce puede entregar la sesión que quiera: por eso
|
|
162
|
+
* el nonce no tiene que llegar a nadie más que a la persona (revisión de integra-06).
|
|
131
163
|
*
|
|
132
164
|
* @param {object} o
|
|
133
165
|
* @param {string} o.webUrl
|
|
@@ -135,13 +167,16 @@ export function abrirNavegador(url, plataforma = process.platform) {
|
|
|
135
167
|
* @param {number} [o.timeoutMs] 3 min por defecto
|
|
136
168
|
* @param {AbortSignal} [o.signal]
|
|
137
169
|
* @param {(info: { puerto: number, estado: string, url: string }) => void} [o.onListening]
|
|
170
|
+
* @param {(url: string) => void} [o.onNoAbrio] el navegador no se abrió (o `abrir` es null): hay que pasar la URL a mano
|
|
171
|
+
* @param {number} [o.graciaMs] cuánto sigue limpiando la barra después de terminar (0: se cierra ya)
|
|
138
172
|
*/
|
|
139
|
-
export async function recibirSesion({ webUrl, abrir = abrirNavegador, timeoutMs = ENTRAR_TIMEOUT_MS, signal, onListening } = {}) {
|
|
173
|
+
export async function recibirSesion({ webUrl, abrir = abrirNavegador, timeoutMs = ENTRAR_TIMEOUT_MS, signal, onListening, onNoAbrio, graciaMs = GRACIA_MS } = {}) {
|
|
140
174
|
if (!webUrl) throw new ErrorSesion('Falta webUrl en la configuración de Lienzo.');
|
|
141
175
|
if (signal?.aborted) throw new ErrorSesion('Se canceló la conexión con tu cuenta.');
|
|
142
176
|
const estado = randomBytes(32).toString('base64url');
|
|
143
177
|
let servidor;
|
|
144
178
|
let timer;
|
|
179
|
+
let cerrado = false; // ya se usó, venció o se canceló: solo limpia la barra
|
|
145
180
|
try {
|
|
146
181
|
return await new Promise((resolve, reject) => {
|
|
147
182
|
let listo = false;
|
|
@@ -161,12 +196,14 @@ export async function recibirSesion({ webUrl, abrir = abrirNavegador, timeoutMs
|
|
|
161
196
|
...cabeceras,
|
|
162
197
|
'content-type': 'text/html; charset=utf-8',
|
|
163
198
|
'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
|
|
164
|
-
}).end(paginaCallback(nonce));
|
|
199
|
+
}).end(paginaCallback(nonce, cerrado));
|
|
165
200
|
}
|
|
166
201
|
if (req.method !== 'POST') return res.writeHead(405, cabeceras).end();
|
|
167
|
-
|
|
202
|
+
if (cerrado) return res.writeHead(410, cabeceras).end();
|
|
203
|
+
// Solo la página que sirvió este mismo servidor (mismo origen): frena a un sitio ajeno abierto en el navegador.
|
|
204
|
+
// NO frena a un proceso local (el Origin se falsifica con curl): eso lo cierra el nonce `estado`, más abajo.
|
|
168
205
|
const origen = req.headers.origin;
|
|
169
|
-
if (origen
|
|
206
|
+
if (origen !== `http://127.0.0.1:${servidor.address().port}`) return res.writeHead(403, cabeceras).end();
|
|
170
207
|
const trozos = [];
|
|
171
208
|
let bytes = 0;
|
|
172
209
|
for await (const c of req) {
|
|
@@ -189,8 +226,13 @@ export async function recibirSesion({ webUrl, abrir = abrirNavegador, timeoutMs
|
|
|
189
226
|
const url = `${webUrl.replace(/\/+$/, '')}/conectar?${new URLSearchParams({ puerto: String(puerto), estado })}`;
|
|
190
227
|
console.error(`[lienzo] para conectar tu cuenta abre: ${url}`);
|
|
191
228
|
onListening?.({ puerto, estado, url });
|
|
229
|
+
if (!abrir) {
|
|
230
|
+
onNoAbrio?.(url);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
192
233
|
Promise.resolve().then(() => abrir(url)).catch((e) => {
|
|
193
234
|
console.error(`[lienzo] no pude abrir el navegador (${e?.message ?? e}); ábrelo a mano: ${url}`);
|
|
235
|
+
onNoAbrio?.(url);
|
|
194
236
|
});
|
|
195
237
|
});
|
|
196
238
|
timer = setTimeout(
|
|
@@ -202,8 +244,13 @@ export async function recibirSesion({ webUrl, abrir = abrirNavegador, timeoutMs
|
|
|
202
244
|
});
|
|
203
245
|
} finally {
|
|
204
246
|
clearTimeout(timer);
|
|
205
|
-
|
|
206
|
-
servidor?.closeAllConnections?.();
|
|
247
|
+
cerrado = true;
|
|
248
|
+
const cerrar = () => { servidor?.close(); servidor?.closeAllConnections?.(); };
|
|
249
|
+
if (servidor?.listening && graciaMs > 0) {
|
|
250
|
+
servidor.unref(); // no retiene al proceso: si el puente termina, la gracia termina con él
|
|
251
|
+
servidor.closeIdleConnections?.();
|
|
252
|
+
setTimeout(cerrar, graciaMs).unref();
|
|
253
|
+
} else cerrar();
|
|
207
254
|
}
|
|
208
255
|
}
|
|
209
256
|
|
|
@@ -406,8 +453,8 @@ export class Sesion {
|
|
|
406
453
|
}
|
|
407
454
|
|
|
408
455
|
/** Login en el navegador + adoptar. Devuelve la cuenta. Opciones: las de recibirSesion. */
|
|
409
|
-
async entrar({ abrir, timeoutMs = ENTRAR_TIMEOUT_MS, signal, onListening } = {}) {
|
|
410
|
-
const { refreshToken } = await recibirSesion({ webUrl: this.config.webUrl, abrir, timeoutMs, signal, onListening });
|
|
456
|
+
async entrar({ abrir, timeoutMs = ENTRAR_TIMEOUT_MS, signal, onListening, onNoAbrio } = {}) {
|
|
457
|
+
const { refreshToken } = await recibirSesion({ webUrl: this.config.webUrl, abrir, timeoutMs, signal, onListening, onNoAbrio });
|
|
411
458
|
return this.adoptar(refreshToken);
|
|
412
459
|
}
|
|
413
460
|
|
|
@@ -423,6 +470,14 @@ export class Sesion {
|
|
|
423
470
|
}
|
|
424
471
|
return this._conCandado(async () => {
|
|
425
472
|
this._cargar(); // otro proceso con el mismo LIENZO_HOME pudo crear o refrescar la sesión
|
|
473
|
+
// lienzo_salir en otra ventana (o la carpeta borrada a mano): la entrada ya no está. No se refresca ni se vuelve
|
|
474
|
+
// a escribir con lo que quedó en memoria: se cierra aquí también (lib/salir.mjs).
|
|
475
|
+
if (this.refreshToken && !(this._clave() in this._leerTodo())) {
|
|
476
|
+
console.error('[lienzo] la sesión se cerró fuera de este proceso (lienzo_salir o la carpeta borrada)');
|
|
477
|
+
for (const k of ['uid', 'refreshToken', 'idToken', 'nombre', 'email', 'proveedor']) this[k] = null;
|
|
478
|
+
this.venceEn = 0;
|
|
479
|
+
if (!this.config.pruebasAnonimas) throw new ErrorSesion(SIN_SESION);
|
|
480
|
+
}
|
|
426
481
|
if (!this.refreshToken) {
|
|
427
482
|
await this._registrarAnonima(); // solo en pruebas; si no, lanza SIN_SESION
|
|
428
483
|
return this.idToken;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ajuarezso/lienzo-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Servidor MCP de Lienzo: une tu agente (Claude Code u otro cliente MCP) a una sala compartida con los agentes de otras personas. / MCP server that joins your agent to a room shared with other people's agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|