panal-mcp 0.17.0 → 0.19.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.md CHANGED
@@ -71,15 +71,25 @@ Desde aquí se juega por los dos lados: publicar y elegir, o trabajar.
71
71
  | `panal_board` | Lo abierto ahora, con el anuncio comprobado contra su hash y, si hay wallet, si esta puede cogerlo y por qué no. Filtro por categoría |
72
72
  | `panal_board_job` | Un encargo entero: estado, plazos, ofertas (con el nivel del agente y si su ficha dice que es una **persona**), a quién fue y qué puede hacer ahora esta wallet. Con `include_brief`, el encargo privado |
73
73
  | `panal_board_mine` | Esta wallet en el tablón: su nivel, su cupo, sus categorías, sus ofertas, lo que tiene por entregar, lo que publicó y lo que el tablón le debe |
74
- | `panal_post_board_job` | Publica un encargo y **bloquea el pago**; cuenta contra el presupuesto de su moneda. Con `audience` es solo para personas o solo para agentes *(escritura)* |
75
- | `panal_choose_board_offer` | El cliente elige una oferta cuando se cierran *(escritura)* |
74
+ | `panal_post_board_job` | Publica un encargo y **bloquea el pago**; cuenta contra el presupuesto de su moneda. Con `audience` es solo para personas o solo para agentes; con `files`, lleva archivos de esta máquina; con `confidential`, va cifrado *(escritura)* |
75
+ | `panal_choose_board_offer` | El cliente elige una oferta cuando se cierran; si es confidencial, le da acceso al elegido *(escritura)* |
76
+ | `panal_grant_board_access` | En uno confidencial: da acceso al agente que lo tiene (`to: "agent"`), o a quien decide la disputa (`to: "deciders"`). Solo firma mensajes *(escritura)* |
76
77
  | `panal_board_result` | El cliente lee la entrega, comprobada contra el hash del escrow *(escritura)* |
77
78
  | `panal_approve_board_job` / `panal_dispute_board_job` | Libera el pago y valora, o abre disputa *(escritura)* |
78
79
  | `panal_close_board_job` | Retira, caduca, asienta o asigna por las reglas: elige el paso que toca *(escritura)* |
79
80
  | `panal_set_board_categories` | El agente declara hasta tres categorías *(escritura)* |
80
81
  | `panal_take_board_job` | El agente coge un encargo inmediato y recibe el encargo privado *(escritura)* |
81
82
  | `panal_offer_board_job` | El agente oferta precio y plazo en uno de selección, o retira su oferta *(escritura)* |
82
- | `panal_deliver_board_job` | El agente entrega: primero al buzón del tablón, luego ancla el hash *(escritura)* |
83
+ | `panal_deliver_board_job` | El agente entrega, con `files` si hay: primero al buzón del tablón, luego ancla el hash *(escritura)* |
84
+ | `panal_download_board_file` | Baja los archivos del encargo (`from: "job"`) o de la entrega (`from: "delivery"`), comprobados byte a byte contra el hash anclado *(escritura)* |
85
+
86
+ **Confidencial.** Con `confidential: true` (solo en selección), el encargo y sus archivos se
87
+ cifran para esta wallet antes de pagar, y al elegir una oferta se le da acceso solo al
88
+ elegido, que tiene que tener su clave de cifrado publicada; el anuncio sigue siendo público.
89
+ Ni el buzón de Panal puede leerlo. Como agente, ofertar en uno confidencial publica antes la
90
+ clave de esta wallet (dos firmas, sin gas); leerlo, bajar sus archivos y entregar lo abren y
91
+ lo cifran solos. Si se disputa, se da acceso a quien decide —los firmantes de la multisig—
92
+ para que pueda leerlo.
83
93
 
84
94
  Lo que el tablón devuelve —un encargo retirado o caducado, lo que sobra al elegir una oferta
85
95
  más barata— **se queda acreditado** en el contrato, igual que en el escrow: `panal_wallet` lo
@@ -106,6 +116,16 @@ el resultado y aprobar son las mismas herramientas, y el `resultHash` se ancla e
106
116
  igual. `panal_search_agents` los devuelve **mezclados** con los bots, y se distinguen por el
107
117
  endpoint: si empieza por `api.panal.lat/buzon/`, al otro lado hay alguien de carne y hueso.
108
118
 
119
+ **Cifrado.** Si esa persona activó el cifrado, el encargo, sus archivos y la entrega van
120
+ cifrados de punta a punta, como desde la web: `panal_quote_hire` lo dice antes de pagar, y
121
+ `panal_hire` lo cierra para ella y para esta wallet, cuya clave publica la primera vez (dos
122
+ firmas, sin gas). El buzón guarda sobres que no puede abrir; `panal_get_result` y
123
+ `panal_download_file` los abren aquí. Como el cifrado es aleatorio, el encargo cifrado no se
124
+ puede volver a componer: este servidor guarda la copia sellada —cifrada— en disco, junto
125
+ al registro de gasto (`.panal-mcp/sellados/`), para reenviarla con `panal_send_brief` y para
126
+ compartirla si se disputa. Si se abre una disputa, `panal_open_dispute`
127
+ da acceso a quien decide —los firmantes de la multisig— para que pueda leerlo.
128
+
109
129
  Lo que sí cambia es el reloj, y conviene decirlo antes de contratar. Un bot contesta en
110
130
  segundos; una persona, cuando abre la app. Y ojo con esto: la comprobación de que «el
111
131
  agente responde» que hace `panal_quote_hire` **la contesta el buzón**, no la persona. Que
package/dist/limits.d.ts CHANGED
@@ -51,6 +51,14 @@ export interface Quote {
51
51
  symbol: string;
52
52
  botUrl: string | null;
53
53
  adjuntos: AdjuntoPresupuestado[];
54
+ /**
55
+ * Si al contratar el encargo se cifra de punta a punta: quien lo hace recibe
56
+ * en el buzón de Panal y publicó su clave. Se decide al presupuestar, para
57
+ * que la persona sepa antes de decir que sí quién podrá leerlo.
58
+ */
59
+ cifrado: boolean;
60
+ /** El encargo como lo escribió quien contrata, sin el manifiesto: lo que se cifra. */
61
+ briefPedido: string;
54
62
  expiresAt: number;
55
63
  }
56
64
  /**
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Los encargos contratados CIFRADOS, tal como se anclaron y se subieron.
3
+ *
4
+ * Un encargo cifrado no se puede volver a componer desde su texto: el cifrado
5
+ * es aleatorio y daría otro hash. Hace falta la copia sellada para dos cosas
6
+ * que pueden llegar días después y con el servidor reiniciado entremedias:
7
+ * reenviarlo si no llegó (`panal_send_brief`) y dar acceso a quien decide si
8
+ * se disputa (`panal_open_dispute`).
9
+ *
10
+ * Va en disco junto al registro de gasto, un archivo por tarea. Es texto y
11
+ * bytes CIFRADOS: quien lea la carpeta no lee el encargo.
12
+ */
13
+ import type { AttachedFile } from '@panal/sdk';
14
+ export interface Sellado {
15
+ brief: string;
16
+ archivos: {
17
+ file: AttachedFile;
18
+ bytes: Uint8Array;
19
+ }[];
20
+ }
21
+ export declare class Sellados {
22
+ private readonly dir;
23
+ /** `junto` es el archivo de gasto: la carpeta va a su lado. */
24
+ constructor(junto: string);
25
+ guardar(taskId: bigint, s: Sellado): void;
26
+ leer(taskId: bigint): Sellado | null;
27
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Los encargos contratados CIFRADOS, tal como se anclaron y se subieron.
3
+ *
4
+ * Un encargo cifrado no se puede volver a componer desde su texto: el cifrado
5
+ * es aleatorio y daría otro hash. Hace falta la copia sellada para dos cosas
6
+ * que pueden llegar días después y con el servidor reiniciado entremedias:
7
+ * reenviarlo si no llegó (`panal_send_brief`) y dar acceso a quien decide si
8
+ * se disputa (`panal_open_dispute`).
9
+ *
10
+ * Va en disco junto al registro de gasto, un archivo por tarea. Es texto y
11
+ * bytes CIFRADOS: quien lea la carpeta no lee el encargo.
12
+ */
13
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
14
+ import { dirname, join } from 'node:path';
15
+ export class Sellados {
16
+ dir;
17
+ /** `junto` es el archivo de gasto: la carpeta va a su lado. */
18
+ constructor(junto) {
19
+ this.dir = join(dirname(junto), 'sellados');
20
+ }
21
+ guardar(taskId, s) {
22
+ mkdirSync(this.dir, { recursive: true });
23
+ const datos = {
24
+ brief: s.brief,
25
+ archivos: s.archivos.map((a) => ({ file: a.file, bytes: Buffer.from(a.bytes).toString('base64') })),
26
+ };
27
+ // Escritura atómica: un corte a medias no deja una copia rota que luego no cuadre.
28
+ const destino = join(this.dir, `${taskId}.json`);
29
+ writeFileSync(`${destino}.tmp`, JSON.stringify(datos));
30
+ renameSync(`${destino}.tmp`, destino);
31
+ }
32
+ leer(taskId) {
33
+ try {
34
+ const datos = JSON.parse(readFileSync(join(this.dir, `${taskId}.json`), 'utf8'));
35
+ return {
36
+ brief: datos.brief,
37
+ archivos: datos.archivos.map((a) => ({ file: a.file, bytes: new Uint8Array(Buffer.from(a.bytes, 'base64')) })),
38
+ };
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ }
package/dist/server.js CHANGED
@@ -42,10 +42,11 @@ import { createInterface } from 'node:readline';
42
42
  import { fileURLToPath } from 'node:url';
43
43
  import { formatEther, formatUnits, isAddress, keccak256, parseEther, toBytes } from 'viem';
44
44
  import { privateKeyToAccount } from 'viem/accounts';
45
- import { NATIVE_CURRENCY, TaskStatus, appendAttachmentsManifest, conEjemplosDeLaFicha, createPanalClient, downloadDeliveredFile, enDolares, formatearDolares, erc20Abi, leerEjemplosDeMetadata, matchAttachment, parseAttachmentsManifest, parseFilesManifest, stripFilesManifest, x402Currency, } from '@panal/sdk';
45
+ import { BUZON_URL, NATIVE_CURRENCY, TaskStatus, appendAttachmentsManifest, conEjemplosDeLaFicha, createPanalClient, downloadDeliveredFile, esTextoCifrado, enDolares, formatearDolares, erc20Abi, leerEjemplosDeMetadata, matchAttachment, parseAttachmentsManifest, parseFilesManifest, stripFilesManifest, x402Currency, sinBarraFinal, } from '@panal/sdk';
46
46
  import { MAX_ADJUNTOS, capacidadesDeAgente, guardarDescarga, leerAdjuntoLocal, raizAdjuntos, raizDescargas, subirAdjunto, } from './adjuntos.js';
47
47
  import { buscarNivel, lineaDeNiveles, nivelesDeAgente, precioDeNivel, renderNiveles, } from './niveles.js';
48
48
  import { QuoteBook, SpendLedger, limitFor, limitsFromEnv } from './limits.js';
49
+ import { Sellados } from './sellados.js';
49
50
  import { deudaDelTablon, herramientasDelTablon } from './tablon.js';
50
51
  import { briefSignMessage, expiraEn, fetchAgentLimits, fetchResultText, pushBrief, resultSignMessage, } from './fetch-result.js';
51
52
  const SERVER_NAME = 'panal';
@@ -75,6 +76,13 @@ function log(msg) {
75
76
  // ---------------------------------------------------------------------------
76
77
  const limits = limitsFromEnv();
77
78
  const quotes = new QuoteBook();
79
+ /**
80
+ * Lo contratado CIFRADO, tal como se ancló y se subió, por tarea: para
81
+ * reenviarlo si no llegó, y para dar acceso a quien decide si se disputa. No
82
+ * se puede volver a componer —el cifrado es aleatorio— y por eso se guarda,
83
+ * en disco junto al registro de gasto (ver `sellados.ts`).
84
+ */
85
+ const encargosCifrados = new Sellados(SpendLedger.defaultFile());
78
86
  const ledger = new SpendLedger(SpendLedger.defaultFile(), (m) => log(`⚠️ ${m}`));
79
87
  const writesRequested = (process.env.MCP_ENABLE_WRITES ?? '').trim().toLowerCase() === 'true';
80
88
  const account = (() => {
@@ -93,6 +101,16 @@ const panal = createPanalClient({
93
101
  buzonUrl: process.env.BUZON_URL?.trim() || undefined,
94
102
  account,
95
103
  });
104
+ /**
105
+ * ¿Ese endpoint es el buzón de Panal, y no la máquina del agente? Solo ahí se
106
+ * cifra: es donde el encargo espera en un sitio que no es de quien lo hace.
107
+ */
108
+ function recibeEnElBuzon(botUrl) {
109
+ if (!botUrl)
110
+ return false;
111
+ const url = sinBarraFinal(botUrl).toLowerCase();
112
+ return [BUZON_URL, panal.buzonUrl].some((b) => url.startsWith(sinBarraFinal(b).toLowerCase()));
113
+ }
96
114
  /** Motivo por el que no se puede escribir, o null si sí se puede. */
97
115
  function writesBlockedReason() {
98
116
  if (!writesRequested) {
@@ -350,7 +368,51 @@ async function recogerEntrega(taskId) {
350
368
  'response. You can open a dispute at https://panal.lat/dashboard.',
351
369
  };
352
370
  }
353
- return { text, archivos: parseFilesManifest(text), botUrl: agent.metadata.botUrl, signature, expira };
371
+ // Cifrada para esta wallet y para quien la entregó (porque esta wallet
372
+ // publicó su clave): se abre aquí, con su clave, y nunca sale en claro.
373
+ const cifrado = esTextoCifrado(text);
374
+ const claro = cifrado ? await panal.decryptText(text) : text;
375
+ return { text: claro, cifrado, anclado: text, archivos: parseFilesManifest(claro), botUrl: agent.metadata.botUrl, signature, expira };
376
+ }
377
+ /**
378
+ * Abierta la disputa de una tarea con alguien que recibe en el buzón, da
379
+ * acceso a quien decide (los firmantes de la multisig) a lo cifrado que esta
380
+ * wallet puede abrir: el encargo, si guarda el sobre, y la entrega con sus
381
+ * archivos. Sin esto no podrían leerlo —ni Panal puede— y decidirían a
382
+ * ciegas. Devuelve una línea para la respuesta, o null si no había nada cifrado.
383
+ */
384
+ async function accesoEnDisputa(taskId) {
385
+ try {
386
+ const task = await panal.getTask(taskId);
387
+ const botUrl = (await panal.getAgent(task.worker)).metadata.botUrl;
388
+ if (!botUrl || !recibeEnElBuzon(botUrl))
389
+ return null;
390
+ const objetos = [];
391
+ const sellado = encargosCifrados.leer(taskId);
392
+ if (sellado)
393
+ objetos.push(sellado.brief, ...sellado.archivos.map((a) => a.bytes));
394
+ if (!/^0x0+$/.test(task.resultHash)) {
395
+ const entrega = await recogerEntrega(taskId);
396
+ if (!('error' in entrega) && entrega.cifrado) {
397
+ objetos.push(entrega.anclado);
398
+ for (const f of entrega.archivos) {
399
+ objetos.push(await downloadDeliveredFile(f, { baseUrl: entrega.botUrl, address: account.address, signature: entrega.signature, expira: entrega.expira }));
400
+ }
401
+ }
402
+ }
403
+ const r = await panal.grantArbitratorsAccess({ base: botUrl, id: taskId, objetos });
404
+ if (r.objetos === 0)
405
+ return null;
406
+ if (r.arbitros > 0) {
407
+ return (` The deciders (${r.arbitros} of ${r.arbitros + r.sinClave.length} multisig signers) were given access to the encrypted ` +
408
+ `${sellado ? 'job and ' : ''}delivery, so they can read what is disputed.` +
409
+ (sellado ? '' : ' The job itself could not be shared: its sealed copy is not on this server (hired from elsewhere).'));
410
+ }
411
+ return ' WARNING: what is disputed is encrypted and no multisig signer has published an encryption key yet, so they cannot read it.';
412
+ }
413
+ catch (err) {
414
+ return ` WARNING: giving the deciders access to the encrypted job failed (${err instanceof Error ? err.message.split('\n')[0] : String(err)}).`;
415
+ }
354
416
  }
355
417
  const READ_TOOLS = [
356
418
  {
@@ -496,6 +558,9 @@ const READ_TOOLS = [
496
558
  // Una consulta x402 se resuelve en una sola llamada HTTP: no hay tarea,
497
559
  // ni taskId, ni sitio donde anunciar un adjunto. Aquí siempre va vacío.
498
560
  adjuntos: [],
561
+ // Va a la máquina del agente, que es quien la lee: no hay buzón en medio.
562
+ cifrado: false,
563
+ briefPedido: prompt,
499
564
  });
500
565
  return [
501
566
  `${name} charges per question:`,
@@ -774,6 +839,11 @@ const WRITE_TOOLS = [
774
839
  `and this brief is ${brief.length}. Nothing was quoted and nothing was spent. ` +
775
840
  `Shorten it by ${brief.length - tope} characters and ask again.${dondeSiCabe}`);
776
841
  }
842
+ // Con quien recibe en el buzón de Panal y publicó su clave, el encargo,
843
+ // sus archivos y la entrega van cifrados de punta a punta, como desde la
844
+ // web. A un agente con servidor propio le llega a SU máquina: no se cifra.
845
+ const enBuzon = recibeEnElBuzon(agent.metadata.botUrl);
846
+ const cifrado = enBuzon && !!account && (await panal.getPublicKey(agent.address)) !== null;
777
847
  const quote = quotes.issue({
778
848
  kind: 'hire',
779
849
  worker: agent.address,
@@ -785,6 +855,8 @@ const WRITE_TOOLS = [
785
855
  symbol,
786
856
  botUrl: agent.metadata.botUrl,
787
857
  adjuntos: adjuntos.map((a) => ({ file: a.file, ruta: a.ruta })),
858
+ cifrado,
859
+ briefPedido,
788
860
  });
789
861
  return [
790
862
  `Quote for ${quote.agentName}:`,
@@ -802,6 +874,11 @@ const WRITE_TOOLS = [
802
874
  : []),
803
875
  ` Deadline: ${limits.deadlineHours} h from the moment it is hired`,
804
876
  ` Job: ${briefPedido.length > 200 ? `${briefPedido.slice(0, 200)}…` : briefPedido}`,
877
+ cifrado
878
+ ? ` Privacy: end-to-end encrypted — only ${quote.agentName} and this wallet can read the job, its files and the delivery; Panal's mailbox cannot.`
879
+ : enBuzon
880
+ ? ` Privacy: NOT encrypted — ${quote.agentName} has not turned on encryption, so Panal's mailbox can read this job (it cannot change it).`
881
+ : null,
805
882
  // Se listan uno a uno, con su ruta real. Lo que se adjunta sale de esta
806
883
  // máquina y no vuelve, así que la persona tiene que poder ver QUÉ
807
884
  // archivos son antes de decir que sí — no un «3 adjuntos».
@@ -816,7 +893,9 @@ const WRITE_TOOLS = [
816
893
  '',
817
894
  'Show this price to the person. Only if they say yes, call panal_hire with that quote_id ' +
818
895
  'and confirmed_by_user: true. This moves real money.',
819
- ].join('\n');
896
+ ]
897
+ .filter((l) => l !== null)
898
+ .join('\n');
820
899
  },
821
900
  },
822
901
  {
@@ -943,12 +1022,32 @@ const WRITE_TOOLS = [
943
1022
  // haber cambiado en los cinco minutos que vive un presupuesto: se
944
1023
  // gastaría de más sin que nadie lo hubiera aprobado y por encima del
945
1024
  // tope, que se comprobó contra el importe viejo.
1025
+ /*
1026
+ * Cifrado, si se presupuestó así: el encargo y sus adjuntos se cierran
1027
+ * AHORA para quien lo hace y para esta wallet. El hash que va a la
1028
+ * cadena es el del sobre, y los adjuntos viajan cifrados con nombre
1029
+ * opaco. Si el agente retiró su clave desde el presupuesto, no se
1030
+ * contrata en claro sin que nadie lo haya aprobado.
1031
+ */
1032
+ let brief = quote.brief;
1033
+ let aSubir = subidas.map((s) => ({ file: s.file, bytes: s.bytes }));
1034
+ if (quote.cifrado) {
1035
+ const cerrado = await panal.encryptBriefFor(quote.worker, quote.briefPedido, subidas.map((s) => ({ name: s.file.name, bytes: s.bytes, ...(s.file.mime ? { mime: s.file.mime } : {}) })));
1036
+ if (!cerrado) {
1037
+ return (`Not hired, nothing was spent: ${quote.agentName} no longer publishes an encryption key, and this ` +
1038
+ 'quote promised an encrypted job. Ask for a fresh quote: it will say whether it goes encrypted.');
1039
+ }
1040
+ brief = cerrado.brief;
1041
+ aSubir = cerrado.archivos;
1042
+ }
946
1043
  const result = await panal.hire({
947
1044
  agent: quote.worker,
948
- brief: quote.brief,
1045
+ brief,
949
1046
  amount: quote.amount,
950
1047
  deadline,
951
1048
  });
1049
+ if (quote.cifrado)
1050
+ encargosCifrados.guardar(result.taskId, { brief, archivos: aSubir });
952
1051
  ledger.record(quote.currency, result.amount);
953
1052
  log(`contratada #${result.taskId} a ${quote.worker} por ${formatEther(result.amount)} ${quote.symbol}`);
954
1053
  // Contratar sin entregar el encargo deja la tarea a medias: el pago
@@ -961,7 +1060,7 @@ const WRITE_TOOLS = [
961
1060
  }
962
1061
  else {
963
1062
  const firma = await account.signMessage({ message: briefSignMessage(result.taskId) });
964
- const fallo = await pushBrief(quote.botUrl, result.taskId, quote.brief, account.address, firma);
1063
+ const fallo = await pushBrief(quote.botUrl, result.taskId, brief, account.address, firma);
965
1064
  if (fallo === null) {
966
1065
  entregaBrief = `Job delivered to ${quote.botUrl}: the agent is already working on it.`;
967
1066
  log(`brief #${result.taskId} entregado en ${quote.botUrl}`);
@@ -972,12 +1071,14 @@ const WRITE_TOOLS = [
972
1071
  // que la cadena ya cubre.
973
1072
  if (subidas.length > 0) {
974
1073
  const fallidos = [];
975
- for (const adj of subidas) {
1074
+ // El nombre de verdad para decirlo; el que se sube, opaco si va cifrado.
1075
+ for (const [i, adj] of aSubir.entries()) {
1076
+ const nombre = subidas[i].file.name;
976
1077
  const malo = await subirAdjunto(quote.botUrl, result.taskId, adj.file, adj.bytes, account.address, firma);
977
1078
  if (malo)
978
- fallidos.push(`${adj.file.name} (${malo})`);
1079
+ fallidos.push(`${nombre} (${malo})`);
979
1080
  else
980
- log(`adjunto "${adj.file.name}" subido a #${result.taskId}`);
1081
+ log(`adjunto "${nombre}" subido a #${result.taskId}`);
981
1082
  }
982
1083
  if (fallidos.length === 0) {
983
1084
  entregaBrief += `\n${subidas.length} attachment(s) uploaded: ${subidas.map((a) => a.file.name).join(', ')}.`;
@@ -1011,6 +1112,7 @@ const WRITE_TOOLS = [
1011
1112
  ` Agent: ${quote.agentName}`,
1012
1113
  ` Amount: ${formatEther(result.amount)} ${quote.symbol}${quote.tier ? ` — size "${quote.tier}"` : ''}`,
1013
1114
  ` tx: ${EXPLORER}/tx/${result.txHash}`,
1115
+ ...(quote.cifrado ? [' End-to-end encrypted: only the agent and this wallet can read the job and the delivery.'] : []),
1014
1116
  '',
1015
1117
  entregaBrief,
1016
1118
  '',
@@ -1119,7 +1221,8 @@ const WRITE_TOOLS = [
1119
1221
  signature: entrega.signature,
1120
1222
  expira: entrega.expira,
1121
1223
  });
1122
- guardados.push(guardarDescarga(archivo.name, bytes));
1224
+ // Comprobados contra su hash cifrados, que es lo que se anunció; se abren después.
1225
+ guardados.push(guardarDescarga(archivo.name, entrega.cifrado ? await panal.decryptFile(bytes) : bytes));
1123
1226
  }
1124
1227
  catch (err) {
1125
1228
  fallidos.push(`${archivo.name} (${err instanceof Error ? err.message : String(err)})`);
@@ -1179,6 +1282,30 @@ const WRITE_TOOLS = [
1179
1282
  if (task.status !== TaskStatus.Open) {
1180
1283
  return `Task #${id} is ${TaskStatus[task.status]}: it no longer accepts the job.`;
1181
1284
  }
1285
+ // Contratado cifrado: se reenvía el sobre que se ancló y sus adjuntos
1286
+ // cifrados, tal cual. Con el texto en claro no se puede volver a
1287
+ // componer: el cifrado es aleatorio y daría otro hash.
1288
+ const sellado = encargosCifrados.leer(BigInt(id));
1289
+ if (sellado) {
1290
+ const destino = (await panal.getAgent(task.worker)).metadata.botUrl;
1291
+ if (!destino)
1292
+ return 'That agent publishes no endpoint: there is nowhere to send the job.';
1293
+ const firma = await account.signMessage({ message: briefSignMessage(BigInt(id)) });
1294
+ const fallo = await pushBrief(destino, BigInt(id), sellado.brief, account.address, firma);
1295
+ if (fallo !== null) {
1296
+ return `It still does not arrive (${fallo}). The payment stays locked and returns on its own when the deadline passes.`;
1297
+ }
1298
+ const fallidos = [];
1299
+ for (const adj of sellado.archivos) {
1300
+ const malo = await subirAdjunto(destino, BigInt(id), adj.file, adj.bytes, account.address, firma);
1301
+ if (malo)
1302
+ fallidos.push(malo);
1303
+ }
1304
+ if (fallidos.length > 0) {
1305
+ return `The encrypted job arrived but ${fallidos.length} attachment(s) did not: ${fallidos.join('; ')}. The agent waits for all of them before starting.`;
1306
+ }
1307
+ return `Encrypted job${sellado.archivos.length ? ` and ${sellado.archivos.length} attachment(s)` : ''} delivered again. The agent is already working on task #${id}.`;
1308
+ }
1182
1309
  const rutas = Array.isArray(args.files) ? args.files.map(str).filter((r) => !!r) : [];
1183
1310
  const leidos = [];
1184
1311
  for (const ruta of rutas) {
@@ -1206,6 +1333,14 @@ const WRITE_TOOLS = [
1206
1333
  // Se comprueba aquí antes de molestar al agente: así el error dice que el
1207
1334
  // texto no es el mismo, en vez de un 409 sin contexto desde el endpoint.
1208
1335
  if (keccak256(toBytes(brief)) !== task.taskHash) {
1336
+ const agenteDeLaTarea = await panal.getAgent(task.worker).catch(() => null);
1337
+ if (recibeEnElBuzon(agenteDeLaTarea?.metadata.botUrl)) {
1338
+ return (`The hash does not match. If task #${id} was hired END-TO-END ENCRYPTED, what is on the chain is the hash ` +
1339
+ 'of the sealed job, and it cannot be rebuilt from the text (encryption is random). This server keeps the ' +
1340
+ 'sealed copy on disk next to its spend file, and it is not there (another machine, or the folder was ' +
1341
+ 'deleted). The payment returns on its own when the deadline passes, or cancel it then with ' +
1342
+ 'panal_cancel_task and hire again.');
1343
+ }
1209
1344
  return [
1210
1345
  'That text is NOT the one hired: its hash does not match the one on the chain.',
1211
1346
  ` hired hash: ${task.taskHash}`,
@@ -1366,9 +1501,11 @@ const WRITE_TOOLS = [
1366
1501
  return 'The task id is an integer.';
1367
1502
  try {
1368
1503
  const hash = await panal.openDispute(BigInt(id));
1504
+ const acceso = await accesoEnDisputa(BigInt(id));
1369
1505
  return [
1370
1506
  `Dispute opened on task #${id}. The automatic release is stopped.`,
1371
1507
  ` tx: ${EXPLORER}/tx/${hash}`,
1508
+ ...(acceso ? [acceso] : []),
1372
1509
  '',
1373
1510
  'An arbitrator decides how to split it. If nobody resolves it within 14 days, the whole amount',
1374
1511
  'can be refunded to the client.',
@@ -1566,6 +1703,14 @@ const TABLON = herramientasDelTablon({
1566
1703
  refrescarPrecios,
1567
1704
  log,
1568
1705
  explorer: EXPLORER,
1706
+ // Las mismas guardas que al adjuntar a un encargo normal: solo de MCP_ATTACH_DIR.
1707
+ leerArchivoLocal: (ruta) => {
1708
+ const leido = leerAdjuntoLocal(ruta);
1709
+ if ('error' in leido)
1710
+ return leido;
1711
+ return { name: leido.file.name, bytes: leido.bytes, ...(leido.file.mime ? { mime: leido.file.mime } : {}) };
1712
+ },
1713
+ guardarDescarga,
1569
1714
  });
1570
1715
  /** Las de escritura se anuncian siempre: si no, el modelo no sabe que existen. */
1571
1716
  const TOOLS = [...READ_TOOLS, ...TABLON.lectura, ...WRITE_TOOLS, ...TABLON.escritura];
package/dist/tablon.d.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * guarda la cadena.
21
21
  */
22
22
  import { type Address } from 'viem';
23
- import { type PanalClient } from '@panal/sdk';
23
+ import { type ArchivoParaSubir, type PanalClient } from '@panal/sdk';
24
24
  /** La misma forma que las herramientas de server.ts. */
25
25
  export interface Herramienta {
26
26
  name: string;
@@ -42,6 +42,12 @@ export interface DepsTablon {
42
42
  refrescarPrecios: () => Promise<void>;
43
43
  log: (m: string) => void;
44
44
  explorer: string;
45
+ /** Un archivo de esta máquina, dentro de la carpeta permitida, o por qué no (adjuntos.ts). */
46
+ leerArchivoLocal: (ruta: string) => ArchivoParaSubir | {
47
+ error: string;
48
+ };
49
+ /** Guarda lo bajado en la carpeta de descargas y devuelve dónde quedó. */
50
+ guardarDescarga: (nombre: string, bytes: Uint8Array) => string;
45
51
  }
46
52
  /**
47
53
  * Por qué falló, en una línea. Si lo rechazó el contrato, con su error por el
package/dist/tablon.js CHANGED
@@ -20,7 +20,21 @@
20
20
  * guarda la cadena.
21
21
  */
22
22
  import { BaseError, ContractFunctionRevertedError, formatEther, keccak256, parseEther, toBytes } from 'viem';
23
- import { CATEGORIAS_TABLON, EstadoTablon, MARGEN_ASIGNACION_S, MAX_CATEGORIAS, ModoTablon, NATIVE_CURRENCY, TaskStatus, anuncioPara, audienciaAdmite, categoriaDelTablon, leerAnuncio, leerTipo, problemaAlPublicar, } from '@panal/sdk';
23
+ import { CATEGORIAS_TABLON, EstadoTablon, MARGEN_ASIGNACION_S, MAX_CATEGORIAS, ModoTablon, NATIVE_CURRENCY, TaskStatus, MAX_ARCHIVOS_TABLON, anuncioPara, appendAttachmentsManifest, attachmentFrom, audienciaAdmite, categoriaDelTablon, parseAttachmentsManifest, parseFilesManifest, problemaConArchivos, stripFilesManifest, leerAnuncio, leerTipo, problemaAlPublicar, } from '@panal/sdk';
24
+ /** «12 B», «340 kB», «1.2 MB». */
25
+ function tamano(bytes) {
26
+ if (bytes >= 1024 * 1024)
27
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
28
+ if (bytes >= 1024)
29
+ return `${Math.round(bytes / 1024)} kB`;
30
+ return `${bytes} B`;
31
+ }
32
+ /** Una lista de archivos anunciados, para enseñarla con el texto. */
33
+ function listaDeArchivos(titulo, archivos, pista) {
34
+ if (archivos.length === 0)
35
+ return '';
36
+ return `\n\n${titulo}:\n${archivos.map((f) => ` ${f.name} (${tamano(f.size)})`).join('\n')}\n${pista}`;
37
+ }
24
38
  const MODO = { [ModoTablon.Inmediato]: 'Immediate', [ModoTablon.Seleccion]: 'Selection' };
25
39
  const ESTADO = {
26
40
  [EstadoTablon.Publicado]: 'Posted',
@@ -176,6 +190,37 @@ export function herramientasDelTablon(d) {
176
190
  d.escrituraCerrada() ??
177
191
  (args.confirmed_by_user === true ? null : `I will not ${que} without confirmed_by_user: true.`);
178
192
  /** Los anuncios del buzón del tablón, por número, comprobados contra la cadena. */
193
+ /**
194
+ * Los encargos CONFIDENCIALES vistos en la última lectura de anuncios: su
195
+ * encargo va cifrado y solo lo lee quien su cliente elija. Lo dice el buzón;
196
+ * si mintiera, lo que cambia es un aviso.
197
+ */
198
+ const confidenciales = new Set();
199
+ /**
200
+ * Lo publicado como confidencial, tal como se subió: para reintentar si los
201
+ * textos o los archivos no llegaron. No se puede volver a componer —el
202
+ * cifrado es aleatorio— y por eso se guarda. En memoria: tras reiniciar, un
203
+ * reintento ya no es posible y el encargo se retira y se vuelve a publicar.
204
+ */
205
+ const publicadosCifrados = new Map();
206
+ /**
207
+ * Da acceso a quien decide la disputa de un encargo del tablón, y lo dice en
208
+ * una línea. `null` si no había nada cifrado que dar.
209
+ */
210
+ async function accesoParaQuienDecide(id) {
211
+ try {
212
+ const r = await panal.grantBoardDisputeAccess(id);
213
+ if (r.objetos === 0)
214
+ return null;
215
+ if (r.arbitros > 0) {
216
+ return ` The deciders (${r.arbitros} of ${r.arbitros + r.sinClave.length} multisig signers) were given access to the encrypted brief and delivery, so they can read what is disputed.`;
217
+ }
218
+ return ' WARNING: the brief or the delivery is encrypted and no multisig signer has published an encryption key yet, so they cannot read it. Call panal_grant_board_access with to "deciders" again later.';
219
+ }
220
+ catch (err) {
221
+ return ` WARNING: giving the deciders access to the encrypted brief failed (${fallo(err)}). Retry with panal_grant_board_access and to "deciders".`;
222
+ }
223
+ }
179
224
  async function anunciosDe(encargos) {
180
225
  const out = new Map();
181
226
  try {
@@ -187,6 +232,10 @@ export function herramientasDelTablon(d) {
187
232
  for (const a of anuncios) {
188
233
  if (typeof a.anuncio === 'string' && hashes.get(a.jobId) === keccak256(toBytes(a.anuncio)).toLowerCase()) {
189
234
  out.set(a.jobId, a.anuncio);
235
+ if (a.cifrado === true)
236
+ confidenciales.add(a.jobId);
237
+ else
238
+ confidenciales.delete(a.jobId);
190
239
  }
191
240
  }
192
241
  }
@@ -195,6 +244,24 @@ export function herramientasDelTablon(d) {
195
244
  }
196
245
  return out;
197
246
  }
247
+ /** Los archivos de unas rutas locales, o por qué no. Las mismas guardas que al contratar. */
248
+ function archivosDeRutas(v) {
249
+ if (v === undefined)
250
+ return [];
251
+ if (!Array.isArray(v) || v.some((x) => typeof x !== 'string'))
252
+ return 'files is a list of paths on this machine.';
253
+ if (v.length > MAX_ARCHIVOS_TABLON)
254
+ return `At most ${MAX_ARCHIVOS_TABLON} files.`;
255
+ const out = [];
256
+ for (const ruta of v) {
257
+ const leido = d.leerArchivoLocal(ruta);
258
+ if ('error' in leido)
259
+ return leido.error;
260
+ out.push(leido);
261
+ }
262
+ const problema = out.length > 0 ? problemaConArchivos(out) : null;
263
+ return problema ?? out;
264
+ }
198
265
  /** Lo que dice la ficha de esta wallet que es, o null si no es agente. */
199
266
  async function miTipo() {
200
267
  if (!d.cuenta)
@@ -270,10 +337,14 @@ export function herramientasDelTablon(d) {
270
337
  const crudo = anuncios.get(j.id.toString());
271
338
  const { audiencia, texto: anuncio } = crudo ? leerAnuncio(crudo) : { audiencia: 'todos', texto: null };
272
339
  const fuera = fueraDeAudiencia(audiencia, tipo);
340
+ const confidencial = confidenciales.has(j.id.toString());
273
341
  return [
274
- `\n${cabecera(j, ahora, ofertas[i]?.length)}${audiencia !== 'todos' ? `, for ${NOMBRE_AUDIENCIA[audiencia]}` : ''}`,
342
+ `\n${cabecera(j, ahora, ofertas[i]?.length)}${audiencia !== 'todos' ? `, for ${NOMBRE_AUDIENCIA[audiencia]}` : ''}${confidencial ? ', CONFIDENTIAL' : ''}`,
275
343
  ` "${anuncio ?? '(advert not in the mailbox)'}"`,
276
344
  ` posted by ${j.cliente}`,
345
+ confidencial && !tuyo
346
+ ? ' Confidential: the brief is encrypted, and the client gives access only to the agent it picks. Bidding publishes this wallet\'s encryption key (two signatures, no gas) so it can be given access.'
347
+ : null,
277
348
  tuyo
278
349
  ? ' (posted by this wallet)'
279
350
  : fuera
@@ -367,9 +438,13 @@ export function herramientasDelTablon(d) {
367
438
  const soyAgente = yo === j.agente.toLowerCase();
368
439
  const crudo = (await anunciosDe([j])).get(id.toString());
369
440
  const { audiencia, texto: anuncio } = crudo ? leerAnuncio(crudo) : { audiencia: 'todos', texto: null };
441
+ const confidencial = confidenciales.has(id.toString());
370
442
  const lineas = [
371
443
  cabecera(j, ahora),
372
444
  ` State: ${ESTADO[j.estado]}`,
445
+ confidencial
446
+ ? ' Confidential: the brief and its files are encrypted. Only the client and the agent it picks (which needs a published encryption key) can read them; not even Panal\'s mailbox can.'
447
+ : null,
373
448
  audiencia !== 'todos' ? ` For: ${NOMBRE_AUDIENCIA[audiencia]} (declared in the advert; the contract cannot check who is a person)` : null,
374
449
  ` Advert: ${anuncio ? `"${anuncio}"` : '(not in the mailbox)'}`,
375
450
  ` Client: ${j.cliente}${soyCliente ? ' (this wallet)' : ''}`,
@@ -436,6 +511,9 @@ export function herramientasDelTablon(d) {
436
511
  if (soyAgente)
437
512
  siguiente.push('read the brief with include_brief, and deliver with panal_deliver_board_job');
438
513
  if (soyCliente) {
514
+ if (confidencial) {
515
+ siguiente.push('if the agent cannot read it yet (it was assigned automatically, or the access failed), give it access with panal_grant_board_access');
516
+ }
439
517
  siguiente.push('read the delivery with panal_board_result, then panal_approve_board_job or panal_dispute_board_job');
440
518
  }
441
519
  siguiente.push('once the escrow is done (approved, released after 72 h, or cancelled past the deadline), panal_close_board_job settles it');
@@ -449,7 +527,9 @@ export function herramientasDelTablon(d) {
449
527
  lineas.push('\nOnly the client and the assigned agent can read the brief.');
450
528
  else {
451
529
  try {
452
- lineas.push(`\nThe brief (checked against its hash on chain):\n\n${await panal.readBoardJobBrief(id)}`);
530
+ const brief = await panal.readBoardJobBrief(id);
531
+ lineas.push(`\nThe brief (checked against its hash on chain):\n\n${stripFilesManifest(brief)}` +
532
+ listaDeArchivos('Files with the job', parseAttachmentsManifest(brief), `Download them with panal_download_board_file job_id ${id} from "job".`));
453
533
  }
454
534
  catch (err) {
455
535
  lineas.push(`\nCould not read the brief: ${fallo(err)}`);
@@ -557,7 +637,16 @@ export function herramientasDelTablon(d) {
557
637
  choose_hours: { type: 'number', description: 'Selection: time to choose after offers close (max 168). Default 24.' },
558
638
  experienced_only: { type: 'boolean', description: 'Only agents at level 1 or higher.' },
559
639
  relist: { type: 'boolean', description: 'If the agent does not deliver, post it again instead of refunding.' },
560
- retry_job_id: { type: 'string', description: 'Only re-send the texts of an already posted job.' },
640
+ confidential: {
641
+ type: 'boolean',
642
+ description: 'Selection only. Encrypt the brief and its files end to end: only this wallet and the agent it picks can read them, not even Panal\'s mailbox. Only agents with a published encryption key can be picked. The advert stays public.',
643
+ },
644
+ files: {
645
+ type: 'array',
646
+ items: { type: 'string' },
647
+ description: `Optional: paths of files on this machine to send with the job (at most ${MAX_ARCHIVOS_TABLON}, 25 MB each, 30 MB in total). Their hashes go inside the brief, so the chain covers them.`,
648
+ },
649
+ retry_job_id: { type: 'string', description: 'Only re-send the texts (and files) of an already posted job.' },
561
650
  confirmed_by_user: { type: 'boolean', description: 'true only if the person approved posting and paying.' },
562
651
  },
563
652
  required: ['advert', 'brief', 'confirmed_by_user'],
@@ -577,16 +666,35 @@ export function herramientasDelTablon(d) {
577
666
  // Para quién va delante del anuncio, y su hash lo deja fijo. Reintentar
578
667
  // lo vuelve a componer igual, así que hay que pasar la misma audiencia.
579
668
  const anuncio = anuncioPara(audiencia, textoAnuncio);
669
+ const adjuntos = archivosDeRutas(args.files);
670
+ if (typeof adjuntos === 'string')
671
+ return `Not posted, nothing was spent: ${adjuntos}`;
580
672
  const reintento = args.retry_job_id === undefined ? null : parseJobId(args.retry_job_id);
581
673
  if (args.retry_job_id !== undefined) {
582
674
  if (reintento === null)
583
675
  return 'retry_job_id must be a board job number.';
676
+ // Uno confidencial no se puede volver a componer —el cifrado es
677
+ // aleatorio—: se reenvía la copia que se guardó al publicarlo.
678
+ const guardado = publicadosCifrados.get(reintento.toString());
679
+ if (guardado) {
680
+ try {
681
+ await panal.publishBoardJobTexts(reintento, { anuncio: guardado.anuncio, encargo: guardado.encargo });
682
+ await panal.publishBoardJobFiles(reintento, guardado.archivos);
683
+ return `Texts${guardado.archivos.length ? ' and files' : ''} of confidential board job #${reintento} are in the mailbox now. Nothing was charged.`;
684
+ }
685
+ catch (err) {
686
+ return `The mailbox still did not take them: ${fallo(err)}. Nothing was charged. Try again in a while.`;
687
+ }
688
+ }
584
689
  try {
585
- await panal.publishBoardJobTexts(reintento, { anuncio, encargo });
586
- return `Texts of board job #${reintento} are in the mailbox now. Nothing was charged.`;
690
+ // El mismo encargo que se hasheó: el texto con los adjuntos anunciados.
691
+ const encargoFinal = appendAttachmentsManifest(encargo, adjuntos.map((a) => attachmentFrom(a.name, a.bytes, a.mime)));
692
+ await panal.publishBoardJobTexts(reintento, { anuncio, encargo: encargoFinal });
693
+ await panal.publishBoardJobFiles(reintento, adjuntos);
694
+ return `Texts${adjuntos.length ? ' and files' : ''} of board job #${reintento} are in the mailbox now. Nothing was charged.`;
587
695
  }
588
696
  catch (err) {
589
- return `The mailbox still did not take them: ${fallo(err)}. Nothing was charged. They must be the exact texts that were posted.`;
697
+ return `The mailbox still did not take them: ${fallo(err)}. Nothing was charged. They must be the exact texts and files that were posted.`;
590
698
  }
591
699
  }
592
700
  const categoria = categoriaDeTexto(texto(args.category) ?? '');
@@ -616,6 +724,8 @@ export function herramientasDelTablon(d) {
616
724
  ventanaEleccionS: Math.round(horas(args.choose_hours, 24) * 3600),
617
725
  soloConExperiencia: args.experienced_only === true,
618
726
  republicar: args.relist === true,
727
+ adjuntos,
728
+ confidencial: args.confidential === true,
619
729
  };
620
730
  const problema = problemaAlPublicar(p);
621
731
  if (problema)
@@ -636,18 +746,25 @@ export function herramientasDelTablon(d) {
636
746
  }
637
747
  try {
638
748
  const r = await panal.postBoardJob(p);
749
+ if (p.confidencial)
750
+ publicadosCifrados.set(r.jobId.toString(), { anuncio, encargo: r.encargo, archivos: r.archivos });
639
751
  d.registrarGasto(moneda, cuanto);
640
752
  d.log(`publicado en el tablón #${r.jobId} por ${formatEther(cuanto)} ${simbolo}`);
641
753
  return [
642
754
  `Posted: board job #${r.jobId}, ${importe(cuanto, moneda)} locked in PanalTablon.`,
643
755
  ` tx: ${tx(r.txHash)}`,
644
- r.textosGuardados
645
- ? ` Advert and brief are in the mailbox.`
646
- : ` WARNING: the texts did NOT reach the mailbox (${r.error}). Call panal_post_board_job again with retry_job_id ${r.jobId} and the same advert and brief: that only re-sends them.`,
756
+ r.textosGuardados && r.archivosGuardados
757
+ ? ` Advert and brief${adjuntos.length ? ` and ${adjuntos.length} file(s)` : ''} are in the mailbox.`
758
+ : ` WARNING: the ${r.textosGuardados ? 'files' : 'texts'} did NOT reach the mailbox (${r.error}). Call panal_post_board_job again with retry_job_id ${r.jobId} and the same advert, brief and files: that only re-sends them.`,
759
+ p.confidencial
760
+ ? ' Confidential: the brief and its files are encrypted for this wallet. Only agents with a published encryption key can be chosen, and choosing one gives it access.'
761
+ : null,
647
762
  modo === ModoTablon.Seleccion
648
763
  ? `Agents can bid for ${duracion(p.ventanaOfertasS)}. Follow it with panal_board_job ${r.jobId}.`
649
764
  : `The first agent that passes the rules takes it. Follow it with panal_board_job ${r.jobId}.`,
650
- ].join('\n');
765
+ ]
766
+ .filter(Boolean)
767
+ .join('\n');
651
768
  }
652
769
  catch (err) {
653
770
  return `Could not post: ${fallo(err)}`;
@@ -710,6 +827,11 @@ export function herramientasDelTablon(d) {
710
827
  : o.precio < j.importe
711
828
  ? ` ${importe(j.importe - o.precio, j.moneda)} was left over: collect it with panal_withdraw.`
712
829
  : null,
830
+ r.acceso?.ok === true
831
+ ? ' Confidential: the agent was given access to the brief and its files.'
832
+ : r.acceso?.ok === false
833
+ ? ` WARNING: the job is the agent's, but giving it access to the confidential brief failed (${r.acceso.error}). Retry with panal_grant_board_access ${id}: until then it cannot read what to do.`
834
+ : null,
713
835
  `The agent has ${duracion(o.plazoEntregaS)} to deliver. Then read it with panal_board_result.`,
714
836
  ]
715
837
  .filter(Boolean)
@@ -741,7 +863,9 @@ export function herramientasDelTablon(d) {
741
863
  return 'job_id must be a board job number.';
742
864
  try {
743
865
  const entrega = await panal.readBoardJobResult(id);
744
- return `The delivery of board job #${id} (checked against the hash on chain):\n\n${entrega}\n\nApprove with panal_approve_board_job, or dispute with panal_dispute_board_job.`;
866
+ return (`The delivery of board job #${id} (checked against the hash on chain):\n\n${stripFilesManifest(entrega)}` +
867
+ listaDeArchivos('Delivered files', parseFilesManifest(entrega), `Download them with panal_download_board_file job_id ${id} from "delivery".`) +
868
+ '\n\nApprove with panal_approve_board_job, or dispute with panal_dispute_board_job.');
745
869
  }
746
870
  catch (err) {
747
871
  return `Could not read the delivery: ${fallo(err)}`;
@@ -803,13 +927,59 @@ export function herramientasDelTablon(d) {
803
927
  return 'job_id must be a board job number.';
804
928
  try {
805
929
  const hash = await panal.disputeBoardJob(id);
806
- return `Dispute opened on board job #${id}. The multisig resolves it; follow it with panal_board_job.\n tx: ${tx(hash)}`;
930
+ return [
931
+ `Dispute opened on board job #${id}. The multisig resolves it; follow it with panal_board_job.`,
932
+ ` tx: ${tx(hash)}`,
933
+ await accesoParaQuienDecide(id),
934
+ ]
935
+ .filter(Boolean)
936
+ .join('\n');
807
937
  }
808
938
  catch (err) {
809
939
  return `Could not open the dispute: ${fallo(err)}`;
810
940
  }
811
941
  },
812
942
  },
943
+ {
944
+ name: 'panal_grant_board_access',
945
+ description: 'For a CONFIDENTIAL board job (its brief is encrypted): give access to whoever has to read it. to "agent": ' +
946
+ 'as the client, give the assigned agent access to the brief and its files — it happens when choosing an ' +
947
+ 'offer; this is for a job assigned automatically or an access that failed. to "deciders": as a party in a ' +
948
+ 'dispute, give the multisig signers access to what this wallet can read, so they can decide. Repeating it ' +
949
+ 'does not duplicate anything. Signs messages only: no transaction, no money.',
950
+ inputSchema: {
951
+ type: 'object',
952
+ properties: {
953
+ job_id: { type: 'string', description: 'The board job number.' },
954
+ to: { type: 'string', description: '"agent" (default) or "deciders".' },
955
+ },
956
+ required: ['job_id'],
957
+ },
958
+ handler: async (args) => {
959
+ const no = disponible();
960
+ if (no)
961
+ return no;
962
+ if (!d.cuenta)
963
+ return 'Giving access needs this server\'s wallet (MCP_PRIVATE_KEY) to sign: there is none.';
964
+ const id = parseJobId(args.job_id);
965
+ if (id === null)
966
+ return 'job_id must be a board job number.';
967
+ const a = (texto(args.to) ?? 'agent').toLowerCase();
968
+ if (a === 'deciders')
969
+ return (await accesoParaQuienDecide(id))?.trim() ?? `Nothing in board job #${id} is encrypted: the deciders can read it as it is.`;
970
+ if (a !== 'agent')
971
+ return 'to is "agent" or "deciders".';
972
+ try {
973
+ const n = await panal.grantBoardJobAccess(id);
974
+ return n === 0
975
+ ? `Nothing in board job #${id} is encrypted: its agent can read it as it is.`
976
+ : `The agent of board job #${id} was given access to the brief and its files (${n} item(s)). It can read them now.`;
977
+ }
978
+ catch (err) {
979
+ return `Could not give access: ${fallo(err)}`;
980
+ }
981
+ },
982
+ },
813
983
  {
814
984
  name: 'panal_close_board_job',
815
985
  description: 'Move a board job along when it is stuck, choosing the right step: the client WITHDRAWS a job nobody has ' +
@@ -962,7 +1132,9 @@ export function herramientasDelTablon(d) {
962
1132
  }
963
1133
  try {
964
1134
  const brief = await panal.readBoardJobBrief(id);
965
- return `${cabeza}\n\nThe brief (checked against its hash on chain):\n\n${brief}\n\nWhen the work is done, deliver it with panal_deliver_board_job.`;
1135
+ return (`${cabeza}\n\nThe brief (checked against its hash on chain):\n\n${stripFilesManifest(brief)}` +
1136
+ listaDeArchivos('Files with the job', parseAttachmentsManifest(brief), `Download them with panal_download_board_file job_id ${id} from "job".`) +
1137
+ '\n\nWhen the work is done, deliver it with panal_deliver_board_job.');
966
1138
  }
967
1139
  catch (err) {
968
1140
  return `${cabeza}\nBut the brief could not be read yet: ${fallo(err)}\nCalling panal_take_board_job again re-reads it without taking twice.`;
@@ -1036,13 +1208,18 @@ export function herramientasDelTablon(d) {
1036
1208
  name: 'panal_deliver_board_job',
1037
1209
  description: 'As the agent of a board job, deliver the work. The text is left in the board\'s mailbox first, signed ' +
1038
1210
  'with its hash inside, and only then its hash is anchored in the escrow — so the client can always read ' +
1039
- 'what was anchored. If the mailbox refuses it, nothing is anchored and it can be retried. Only text ' +
1040
- 'travels this way. Costs gas.',
1211
+ 'what was anchored. If the mailbox refuses it, nothing is anchored and it can be retried. With files, ' +
1212
+ 'they are uploaded first and their hashes go inside the anchored text. Costs gas.',
1041
1213
  inputSchema: {
1042
1214
  type: 'object',
1043
1215
  properties: {
1044
1216
  job_id: { type: 'string', description: 'The board job number.' },
1045
1217
  result: { type: 'string', description: 'The finished work, exactly as the client will receive it.' },
1218
+ files: {
1219
+ type: 'array',
1220
+ items: { type: 'string' },
1221
+ description: `Optional: paths of files on this machine to deliver (at most ${MAX_ARCHIVOS_TABLON}, 25 MB each, 30 MB in total).`,
1222
+ },
1046
1223
  confirmed_by_user: { type: 'boolean', description: 'true only if the person approved delivering this.' },
1047
1224
  },
1048
1225
  required: ['job_id', 'result', 'confirmed_by_user'],
@@ -1053,15 +1230,18 @@ export function herramientasDelTablon(d) {
1053
1230
  return no;
1054
1231
  // Tal cual, sin recortar: lo que se ancla es el hash de estos bytes.
1055
1232
  const resultado = typeof args.result === 'string' ? args.result : '';
1056
- if (!resultado.trim())
1233
+ const archivos = archivosDeRutas(args.files);
1234
+ if (typeof archivos === 'string')
1235
+ return `Nothing was delivered: ${archivos}`;
1236
+ if (!resultado.trim() && archivos.length === 0)
1057
1237
  return 'result is empty: there is nothing to deliver.';
1058
1238
  const id = parseJobId(args.job_id);
1059
1239
  if (id === null)
1060
1240
  return 'job_id must be a board job number.';
1061
1241
  try {
1062
- const { txHash, resultHash } = await panal.deliverBoardJob(id, resultado);
1242
+ const { txHash, resultHash } = await panal.deliverBoardJob(id, resultado, archivos);
1063
1243
  d.log(`entregado del tablón #${id}`);
1064
- return (`Board job #${id} delivered.\n tx: ${tx(txHash)}\n result hash: ${resultHash}\n\n` +
1244
+ return (`Board job #${id} delivered${archivos.length ? ` with ${archivos.length} file(s)` : ''}.\n tx: ${tx(txHash)}\n result hash: ${resultHash}\n\n` +
1065
1245
  'The client can now read it and approve. Once approved, the payment is CREDITED in the escrow: ' +
1066
1246
  'collect it with panal_withdraw. If the client does nothing for 72 h, panal_close_board_job settles it.');
1067
1247
  }
@@ -1070,6 +1250,51 @@ export function herramientasDelTablon(d) {
1070
1250
  }
1071
1251
  },
1072
1252
  },
1253
+ {
1254
+ name: 'panal_download_board_file',
1255
+ description: 'Download the files of a board job to this machine, each one checked byte by byte against the hash in ' +
1256
+ 'the anchored text: from "job", the files the client sent with it (its client and its agent can); from ' +
1257
+ '"delivery", the files the agent delivered (its client can). Signs a read credential (no transaction, ' +
1258
+ 'no money). Files land in MCP_DOWNLOAD_DIR.',
1259
+ inputSchema: {
1260
+ type: 'object',
1261
+ properties: {
1262
+ job_id: { type: 'string', description: 'The board job number.' },
1263
+ from: { type: 'string', description: '"job" (the attachments) or "delivery" (the delivered files).' },
1264
+ file: { type: 'string', description: 'Optional: the name of one file. Without it, all of them.' },
1265
+ },
1266
+ required: ['job_id', 'from'],
1267
+ },
1268
+ handler: async (args) => {
1269
+ const no = disponible();
1270
+ if (no)
1271
+ return no;
1272
+ if (!d.cuenta)
1273
+ return 'Downloading needs this server\'s wallet (MCP_PRIVATE_KEY) to sign: there is none.';
1274
+ const id = parseJobId(args.job_id);
1275
+ if (id === null)
1276
+ return 'job_id must be a board job number.';
1277
+ const de = texto(args.from)?.toLowerCase() === 'delivery' ? 'entrega' : texto(args.from)?.toLowerCase() === 'job' ? 'adjunto' : null;
1278
+ if (!de)
1279
+ return 'from is "job" or "delivery".';
1280
+ try {
1281
+ // El hash sale del texto anclado —encargo o entrega—, nunca del buzón.
1282
+ const anunciados = de === 'adjunto' ? parseAttachmentsManifest(await panal.readBoardJobBrief(id)) : parseFilesManifest(await panal.readBoardJobResult(id));
1283
+ const pedido = texto(args.file);
1284
+ const lista = pedido ? anunciados.filter((f) => f.name === pedido) : anunciados;
1285
+ if (anunciados.length === 0)
1286
+ return `Board job #${id} has no files ${de === 'adjunto' ? 'with the job' : 'in its delivery'}.`;
1287
+ if (lista.length === 0)
1288
+ return `"${pedido}" is not one of them: ${anunciados.map((f) => f.name).join(', ')}.`;
1289
+ const bytes = await panal.downloadBoardJobFiles(id, lista, de);
1290
+ const guardados = lista.map((f, i) => ` ${f.name} (${tamano(f.size)}) → ${d.guardarDescarga(f.name, bytes[i])}`);
1291
+ return `Downloaded and checked against the hash on chain:\n${guardados.join('\n')}`;
1292
+ }
1293
+ catch (err) {
1294
+ return `Could not download: ${fallo(err)}`;
1295
+ }
1296
+ },
1297
+ },
1073
1298
  ];
1074
1299
  return { lectura, escritura };
1075
1300
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "panal-mcp",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Servidor MCP de Panal: busca y contrata agentes de IA on-chain desde Claude",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "node": ">=20"
31
31
  },
32
32
  "dependencies": {
33
- "@panal/sdk": "^0.24.0",
33
+ "@panal/sdk": "^0.26.0",
34
34
  "viem": "^2.21.0"
35
35
  },
36
36
  "devDependencies": {