agents-city 0.5.1 → 0.5.3
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/.claude-plugin/marketplace.json +1 -1
- package/bin/navegador.mjs +108 -0
- package/bin/serve.py +112 -4
- package/bin/test +2 -1
- package/bin/test-arnes.py +109 -0
- package/bin/test-channel.py +34 -2
- package/bin/test-diario.py +185 -0
- package/bin/test-navegador.py +9 -0
- package/bin/test-seat.py +52 -0
- package/bin/test-serve.py +98 -0
- package/city/web/dist-hall/hall.js +57 -6
- package/city/web/src/casa.ts +46 -1
- package/city/web/src/hall.ts +52 -5
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/scripts/arnes.py +10 -1
- package/plugin/scripts/diario.py +119 -0
- package/plugin/scripts/doctor.py +53 -0
package/bin/navegador.mjs
CHANGED
|
@@ -553,6 +553,114 @@ async function main() {
|
|
|
553
553
|
JSON.stringify(demo?.dice),
|
|
554
554
|
);
|
|
555
555
|
|
|
556
|
+
// Building a house for real: type a name, press the button, get a house.
|
|
557
|
+
//
|
|
558
|
+
// The suite used to open this form, check its fields existed and press
|
|
559
|
+
// Escape — so the one thing it is FOR was never exercised. What that hid:
|
|
560
|
+
// the roles list arrives a few hundred milliseconds after the form opens,
|
|
561
|
+
// the form repainted when it did, and the repaint redrew the name field
|
|
562
|
+
// from a state that had never been told what was typed in it. Typing while
|
|
563
|
+
// that answer was in flight lost the name, and then the button said to
|
|
564
|
+
// give it one. Intermittent, which is the worst kind.
|
|
565
|
+
//
|
|
566
|
+
// So this types IMMEDIATELY, before the roles can land, which is exactly
|
|
567
|
+
// what a person does.
|
|
568
|
+
const construida = await cdp.evalua(`(async () => {
|
|
569
|
+
const espera = async (sel, n = 40) => {
|
|
570
|
+
for (let i = 0; i < n; i++) {
|
|
571
|
+
const el = document.querySelector(sel);
|
|
572
|
+
if (el) return el;
|
|
573
|
+
await new Promise(r => setTimeout(r, 250));
|
|
574
|
+
}
|
|
575
|
+
return null;
|
|
576
|
+
};
|
|
577
|
+
const ir = [...document.querySelectorAll('nav li')].find(l => /houses|casas/i.test(l.textContent));
|
|
578
|
+
ir?.click();
|
|
579
|
+
await new Promise(r => setTimeout(r, 600));
|
|
580
|
+
document.getElementById('altaAgente')?.click();
|
|
581
|
+
const campo = await espera('.dlgFondo #bvNombre');
|
|
582
|
+
if (!campo) return { falta: true };
|
|
583
|
+
// Straight away: the roles request is still in flight right now.
|
|
584
|
+
campo.value = 'urgencias';
|
|
585
|
+
campo.dispatchEvent(new Event('input', { bubbles: true }));
|
|
586
|
+
// Long enough for that answer to land and do whatever it does.
|
|
587
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
588
|
+
const sobrevive = (document.querySelector('.dlgFondo #bvNombre') || {}).value;
|
|
589
|
+
const boton = document.querySelector('.dlgFondo [data-dlg="si"]');
|
|
590
|
+
boton?.click();
|
|
591
|
+
const leeNombres = () =>
|
|
592
|
+
[...document.querySelectorAll('.fichaRPG h3')].map(h => h.textContent.trim());
|
|
593
|
+
// Wait for the house, not for a guess at how long building takes. A
|
|
594
|
+
// fixed sleep here made this check itself flaky, which is the one thing
|
|
595
|
+
// a check for a flaky bug must not be.
|
|
596
|
+
let nombres = [];
|
|
597
|
+
for (let i = 0; i < 60; i++) {
|
|
598
|
+
nombres = leeNombres();
|
|
599
|
+
if (!document.querySelector('.dlgFondo') && nombres.includes('urgencias')) break;
|
|
600
|
+
await new Promise(r => setTimeout(r, 250));
|
|
601
|
+
}
|
|
602
|
+
return { sobrevive, cerrado: !document.querySelector('.dlgFondo'), nombres };
|
|
603
|
+
})()`);
|
|
604
|
+
comprueba(
|
|
605
|
+
'a name typed while the roles are still loading is still there afterwards',
|
|
606
|
+
construida?.sobrevive === 'urgencias',
|
|
607
|
+
JSON.stringify(construida),
|
|
608
|
+
);
|
|
609
|
+
comprueba(
|
|
610
|
+
'and pressing build actually builds the house',
|
|
611
|
+
!!construida?.cerrado && (construida?.nombres ?? []).includes('urgencias'),
|
|
612
|
+
JSON.stringify(construida),
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
// And the other half: pressing build with nothing in the name.
|
|
616
|
+
//
|
|
617
|
+
// The failure this guards is not "it refuses" — it is refusing SILENTLY, or
|
|
618
|
+
// refusing while closing the dialog, which loses everything the person had
|
|
619
|
+
// already chosen. That is the same complaint from the other side: a form
|
|
620
|
+
// that says no and takes the work with it.
|
|
621
|
+
const vacia = await cdp.evalua(`(async () => {
|
|
622
|
+
const espera = async (sel, n = 40) => {
|
|
623
|
+
for (let i = 0; i < n; i++) {
|
|
624
|
+
const el = document.querySelector(sel);
|
|
625
|
+
if (el) return el;
|
|
626
|
+
await new Promise(r => setTimeout(r, 250));
|
|
627
|
+
}
|
|
628
|
+
return null;
|
|
629
|
+
};
|
|
630
|
+
const antes = document.querySelectorAll('.fichaRPG h3').length;
|
|
631
|
+
document.getElementById('altaAgente')?.click();
|
|
632
|
+
const campo = await espera('.dlgFondo #bvNombre');
|
|
633
|
+
if (!campo) return { falta: true };
|
|
634
|
+
// Choose something first: whatever it says, this must not be thrown away.
|
|
635
|
+
document.querySelector('.dlgFondo [data-bv=\"clase\"][data-id=\"knowledge\"]')?.click();
|
|
636
|
+
await new Promise(r => setTimeout(r, 300));
|
|
637
|
+
document.querySelector('.dlgFondo [data-dlg=\"si\"]')?.click();
|
|
638
|
+
await new Promise(r => setTimeout(r, 1200));
|
|
639
|
+
const caja = document.querySelector('.dlgFondo');
|
|
640
|
+
const elegida = !!document.querySelector('.dlgFondo .bvOpcion.elegida[data-id=\"knowledge\"]');
|
|
641
|
+
const aviso = document.querySelector('#aviso,.aviso,.toast');
|
|
642
|
+
const fuera = {
|
|
643
|
+
sigueAbierto: !!caja,
|
|
644
|
+
elegida,
|
|
645
|
+
dice: (aviso?.textContent ?? '').trim().slice(0, 80),
|
|
646
|
+
despues: document.querySelectorAll('.fichaRPG h3').length,
|
|
647
|
+
antes,
|
|
648
|
+
};
|
|
649
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
650
|
+
await new Promise(r => setTimeout(r, 200));
|
|
651
|
+
return fuera;
|
|
652
|
+
})()`);
|
|
653
|
+
comprueba(
|
|
654
|
+
'building with no name refuses without closing the form',
|
|
655
|
+
!!vacia && !vacia.falta && vacia.sigueAbierto === true,
|
|
656
|
+
JSON.stringify(vacia),
|
|
657
|
+
);
|
|
658
|
+
comprueba(
|
|
659
|
+
'and keeps what was already chosen, and builds nothing',
|
|
660
|
+
!!vacia?.elegida && vacia?.despues === vacia?.antes,
|
|
661
|
+
JSON.stringify(vacia),
|
|
662
|
+
);
|
|
663
|
+
|
|
556
664
|
// Spanish and English, on a switch. The READMEs shipped bilingual and the
|
|
557
665
|
// product did not; a language button that does not actually change the page
|
|
558
666
|
// is worse than none at all.
|
package/bin/serve.py
CHANGED
|
@@ -29,6 +29,7 @@ import stat
|
|
|
29
29
|
import subprocess
|
|
30
30
|
import sys
|
|
31
31
|
import threading
|
|
32
|
+
import time
|
|
32
33
|
import urllib.parse
|
|
33
34
|
import webbrowser
|
|
34
35
|
|
|
@@ -44,6 +45,7 @@ import parcels # noqa: E402
|
|
|
44
45
|
import units # noqa: E402
|
|
45
46
|
import busca # noqa: E402 the disk scanner, and the one list of where work lives
|
|
46
47
|
import cities # noqa: E402
|
|
48
|
+
import diario # noqa: E402 what happened, written down
|
|
47
49
|
import demos # noqa: E402 the recorded demos the Hall plays back
|
|
48
50
|
import roads # noqa: E402
|
|
49
51
|
import reception # noqa: E402
|
|
@@ -523,6 +525,15 @@ def actividad_viva(datos):
|
|
|
523
525
|
return {"online": False, "url": "", "city": "", "started_at": ""}
|
|
524
526
|
|
|
525
527
|
|
|
528
|
+
def _resumen_cuerpo(cuerpo):
|
|
529
|
+
"""On success, the shape rather than the content: which fields arrived and
|
|
530
|
+
how long each was. A log that repeats every goal somebody types is a log
|
|
531
|
+
they will not send anywhere."""
|
|
532
|
+
if not isinstance(cuerpo, dict):
|
|
533
|
+
return {}
|
|
534
|
+
return {k: (len(v) if isinstance(v, (str, list)) else v) for k, v in cuerpo.items()}
|
|
535
|
+
|
|
536
|
+
|
|
526
537
|
def _que_es_git(ruta):
|
|
527
538
|
"""`repo`, `worktree`, or nothing. A label on a row, never a filter: the
|
|
528
539
|
picker shows every folder and lets the person decide which one matters."""
|
|
@@ -603,7 +614,44 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
603
614
|
if os.environ.get("CITY_SETUP_DEBUG"):
|
|
604
615
|
sys.stderr.write(" %s\n" % (format % args))
|
|
605
616
|
|
|
617
|
+
#: What this handler last answered, so the request log can say so without
|
|
618
|
+
#: every endpoint having to report for itself.
|
|
619
|
+
_ultimo = None
|
|
620
|
+
_error = None
|
|
621
|
+
|
|
622
|
+
def _ciudad_para_apuntar(self, q):
|
|
623
|
+
"""The same city `ciudad` would resolve, resolved without touching it.
|
|
624
|
+
|
|
625
|
+
`ciudad` calls `asegura_metadata`, which does `makedirs` and writes
|
|
626
|
+
`city.yml` — right when a request is about to act on a city, and wrong
|
|
627
|
+
when the only reason we are asking is to write a log line. The journal
|
|
628
|
+
runs AFTER the handler, so on a request that archived or reset a city it
|
|
629
|
+
recreated the very folder that had just been taken away, leaving a ghost
|
|
630
|
+
behind. Observing something must not create it.
|
|
631
|
+
"""
|
|
632
|
+
pedida = q.get("city", [""])[0]
|
|
633
|
+
if pedida:
|
|
634
|
+
resuelta = self.resuelve_conocida(pedida)
|
|
635
|
+
if resuelta:
|
|
636
|
+
return resuelta
|
|
637
|
+
return seat.donde_viven_las_fichas()
|
|
638
|
+
|
|
639
|
+
def apunta(self, q, tipo, **campos):
|
|
640
|
+
"""One journal line, in the journal of the city the request acted on.
|
|
641
|
+
|
|
642
|
+
Not the selected one: a request carries `?city=`, and writing its line
|
|
643
|
+
into whichever city happened to be current put the record of what
|
|
644
|
+
happened to one city in another city's file. Which is worse than no
|
|
645
|
+
record, because it is a record that lies about where.
|
|
646
|
+
"""
|
|
647
|
+
try:
|
|
648
|
+
diario.apunta(self._ciudad_para_apuntar(q), tipo, **campos)
|
|
649
|
+
except Exception: # noqa: BLE001 logging must not break the request
|
|
650
|
+
pass
|
|
651
|
+
|
|
606
652
|
def responde(self, cuerpo, tipo="application/json", codigo=200):
|
|
653
|
+
self._ultimo = codigo
|
|
654
|
+
self._error = cuerpo.get("error") if isinstance(cuerpo, dict) else None
|
|
607
655
|
if not isinstance(cuerpo, bytes):
|
|
608
656
|
cuerpo = json.dumps(cuerpo).encode()
|
|
609
657
|
self.send_response(codigo)
|
|
@@ -690,6 +738,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
690
738
|
"/api/instrucciones": "g_instrucciones",
|
|
691
739
|
"/api/live": "g_live",
|
|
692
740
|
"/api/carpeta": "g_carpeta",
|
|
741
|
+
"/api/diario": "g_diario",
|
|
693
742
|
"/api/demos": "g_demos",
|
|
694
743
|
"/api/domains": "g_domains",
|
|
695
744
|
"/api/roles": "g_roles",
|
|
@@ -707,6 +756,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
707
756
|
"/api/roads": "p_roads",
|
|
708
757
|
"/api/reception": "p_reception",
|
|
709
758
|
"/api/agente": "p_agente",
|
|
759
|
+
"/api/diario": "p_diario",
|
|
710
760
|
"/api/agentes": "p_agentes",
|
|
711
761
|
"/api/montaje": "p_montaje",
|
|
712
762
|
"/api/motor": "p_motor",
|
|
@@ -759,9 +809,24 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
759
809
|
cuerpo = json.loads(self.rfile.read(largo) or b"{}")
|
|
760
810
|
except (ValueError, json.JSONDecodeError) as e:
|
|
761
811
|
return self.responde({"error": f"unreadable body: {e}"}, codigo=400)
|
|
762
|
-
if ruta in self.POSTS:
|
|
763
|
-
|
|
764
|
-
|
|
812
|
+
if ruta not in self.POSTS:
|
|
813
|
+
self.apunta(q, "post", ruta=ruta, estado=404, error="no such thing")
|
|
814
|
+
return self.responde({"error": "no such thing"}, codigo=404)
|
|
815
|
+
# Every write is recorded before it happens and judged after, so a
|
|
816
|
+
# request that never came back says so too — a handler that hangs or
|
|
817
|
+
# dies leaves a line with no verdict, which is itself the finding.
|
|
818
|
+
empezado = time.monotonic()
|
|
819
|
+
self._ultimo = None
|
|
820
|
+
try:
|
|
821
|
+
salida = getattr(self, self.POSTS[ruta])(q, cuerpo)
|
|
822
|
+
except Exception as e: # noqa: BLE001 the log is the point
|
|
823
|
+
self.apunta(q, "post", ruta=ruta, error=f"{type(e).__name__}: {e}",
|
|
824
|
+
cuerpo=cuerpo, ms=int((time.monotonic() - empezado) * 1000))
|
|
825
|
+
raise
|
|
826
|
+
self.apunta(q, "post", ruta=ruta, estado=self._ultimo,
|
|
827
|
+
cuerpo=cuerpo if self._ultimo != 200 else _resumen_cuerpo(cuerpo),
|
|
828
|
+
error=self._error, ms=int((time.monotonic() - empezado) * 1000))
|
|
829
|
+
return salida
|
|
765
830
|
|
|
766
831
|
def g_estado(self, q):
|
|
767
832
|
datos = self.ciudad(q)
|
|
@@ -871,6 +936,16 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
871
936
|
return self.responde({"error": "no such demo"}, codigo=404)
|
|
872
937
|
return self.responde({**(demos.ficha(cual, eventos) or {}), "eventos": eventos})
|
|
873
938
|
|
|
939
|
+
def g_diario(self, q):
|
|
940
|
+
"""The journal, for `doctor --log` and for anybody about to send it."""
|
|
941
|
+
try:
|
|
942
|
+
cuantas = max(1, min(2000, int(q.get("n", ["200"])[0])))
|
|
943
|
+
except ValueError:
|
|
944
|
+
cuantas = 200
|
|
945
|
+
return self.responde(
|
|
946
|
+
{"ruta": diario.ruta(self.ciudad(q)), "lineas": diario.lee(self.ciudad(q), cuantas)}
|
|
947
|
+
)
|
|
948
|
+
|
|
874
949
|
def g_carpeta(self, q):
|
|
875
950
|
"""One folder, listed: what is in it, and what each thing is.
|
|
876
951
|
|
|
@@ -1323,8 +1398,23 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
1323
1398
|
return self.responde({"error": "this city has no owner card yet"}, codigo=409)
|
|
1324
1399
|
nombre = " ".join(str(cuerpo.get("name") or "").split())
|
|
1325
1400
|
slug = card.ventana(nombre)
|
|
1326
|
-
|
|
1401
|
+
# `card.ventana` falls back to the word `repo` when nothing in the name
|
|
1402
|
+
# survives slugging — which is right for a repository whose folder is
|
|
1403
|
+
# punctuation, and wrong here: a house called `///` would be created
|
|
1404
|
+
# with the window `repo`, and the name a person reads would stop being
|
|
1405
|
+
# the thing the city addresses. A name has to carry a letter or a digit.
|
|
1406
|
+
legible = bool(re.search(r"[a-z0-9]", nombre.lower()))
|
|
1407
|
+
if not nombre or not card.ventana_valida(slug) or not legible:
|
|
1327
1408
|
return self.responde({"error": "an agent needs a plain name"}, codigo=400)
|
|
1409
|
+
# A window slug is cut at 80 characters, so a longer name would be
|
|
1410
|
+
# stored in full on the card and addressed by a truncated one — the
|
|
1411
|
+
# name a person reads and the window it opens quietly stop being the
|
|
1412
|
+
# same thing. Refuse rather than silently rename.
|
|
1413
|
+
if len(nombre) > 80:
|
|
1414
|
+
return self.responde(
|
|
1415
|
+
{"error": "an agent's name has to fit in a window title: 80 characters"},
|
|
1416
|
+
codigo=400,
|
|
1417
|
+
)
|
|
1328
1418
|
clase = str(cuerpo.get("kind") or workspace.CLASE_DEFECTO).strip().lower()
|
|
1329
1419
|
if clase not in workspace.CLASES:
|
|
1330
1420
|
return self.responde(
|
|
@@ -1419,6 +1509,24 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
1419
1509
|
}
|
|
1420
1510
|
)
|
|
1421
1511
|
|
|
1512
|
+
def p_diario(self, q, cuerpo):
|
|
1513
|
+
"""The browser's half of the log.
|
|
1514
|
+
|
|
1515
|
+
Half of what goes wrong here goes wrong in the page — a handler that
|
|
1516
|
+
threw, a fetch that never came back, a button that did nothing. A log
|
|
1517
|
+
that stops at the network boundary tells half the story, so the page
|
|
1518
|
+
writes into the same file the server does, and one file answers "what
|
|
1519
|
+
happened" instead of two that have to be lined up by hand.
|
|
1520
|
+
"""
|
|
1521
|
+
diario.apunta(
|
|
1522
|
+
self.ciudad(q),
|
|
1523
|
+
"browser",
|
|
1524
|
+
que=str(cuerpo.get("que") or "")[:120],
|
|
1525
|
+
detalle=cuerpo.get("detalle"),
|
|
1526
|
+
donde=str(cuerpo.get("donde") or "")[:200],
|
|
1527
|
+
)
|
|
1528
|
+
return self.responde({"ok": True})
|
|
1529
|
+
|
|
1422
1530
|
def p_agente(self, q, cuerpo):
|
|
1423
1531
|
"""Tune one agent from its character sheet: model, effort, runtime and
|
|
1424
1532
|
avatar seed, written to the card keys the launcher already resolves. An
|
package/bin/test
CHANGED
|
@@ -22,7 +22,7 @@ if [ -d .git ] && [ -x .githooks/pre-commit ] \
|
|
|
22
22
|
&& printf ' pre-commit gate armed (core.hooksPath .githooks)\n'
|
|
23
23
|
fi
|
|
24
24
|
|
|
25
|
-
SUITES=(widgets card parcels domains busca arnes i18n serve seat cities channel connect committee live-feed runtime claude-runtime runtime-ui runtime-failures stress adapter benchmark demo contracts exit rutas cage broker evidencia admision pairing hall-protocol doctor security workspace avatar crecimiento atajos navegador actualiza desinstala launch)
|
|
25
|
+
SUITES=(widgets card parcels domains busca arnes i18n diario serve seat cities channel connect committee live-feed runtime claude-runtime runtime-ui runtime-failures stress adapter benchmark demo contracts exit rutas cage broker evidencia admision pairing hall-protocol doctor security workspace avatar crecimiento atajos navegador actualiza desinstala launch)
|
|
26
26
|
[ $# -gt 0 ] && SUITES=("$@")
|
|
27
27
|
|
|
28
28
|
fallos=0
|
|
@@ -91,6 +91,7 @@ necesarios = ["bin/agents-city.js", "bin/hall", "bin/seat", "bin/city", "bin/dem
|
|
|
91
91
|
"plugin/scripts/crecimiento.py",
|
|
92
92
|
"plugin/scripts/reset.py", "plugin/scripts/desinstala.py",
|
|
93
93
|
"plugin/scripts/demos.py", "plugin/scripts/arnes.py",
|
|
94
|
+
"plugin/scripts/diario.py",
|
|
94
95
|
"plugin/channel/runtime/arnes.json", "plugin/scripts/capabilities.py",
|
|
95
96
|
"plugin/scripts/deliberations.py",
|
|
96
97
|
"plugin/scripts/runtime_processes.py",
|
package/bin/test-arnes.py
CHANGED
|
@@ -20,6 +20,7 @@ So there are two things to defend here, and the second is the hard one:
|
|
|
20
20
|
import json
|
|
21
21
|
import os
|
|
22
22
|
import re
|
|
23
|
+
import subprocess
|
|
23
24
|
import sys
|
|
24
25
|
import tempfile
|
|
25
26
|
|
|
@@ -119,12 +120,119 @@ def sin_deriva():
|
|
|
119
120
|
arnes.banderas("codex") == "" and arnes.banderas("opencode") == "",
|
|
120
121
|
f"codex={arnes.banderas('codex')!r}")
|
|
121
122
|
|
|
123
|
+
# And it survives a SHELL, which is what receives it.
|
|
124
|
+
#
|
|
125
|
+
# This is the check that was missing, and its absence cost every Claude
|
|
126
|
+
# window in a city. Emitted bare, `--settings {"a":"b","c":true}` is
|
|
127
|
+
# destroyed twice before Claude sees it: brace expansion splits it on the
|
|
128
|
+
# comma, quote removal eats the double quotes, and what arrives is
|
|
129
|
+
# `--settings {a:b}` — "Invalid JSON provided to --settings".
|
|
130
|
+
#
|
|
131
|
+
# Asserting that the string CONTAINS the right words could never have
|
|
132
|
+
# caught that. So this parses the line the way a shell does and reads the
|
|
133
|
+
# value back as JSON, which is the only claim that matters.
|
|
134
|
+
import shlex # noqa: PLC0415
|
|
135
|
+
|
|
136
|
+
palabras = shlex.split(arnes.banderas("claude"))
|
|
137
|
+
afirma("· the flags survive shell parsing as separate words",
|
|
138
|
+
"--settings" in palabras and "--disallowed-tools" in palabras, str(palabras))
|
|
139
|
+
valor = palabras[palabras.index("--settings") + 1]
|
|
140
|
+
try:
|
|
141
|
+
ajustes = json.loads(valor)
|
|
142
|
+
except json.JSONDecodeError as e:
|
|
143
|
+
ajustes = None
|
|
144
|
+
afirma("· and the settings value is still JSON afterwards", False, f"{valor!r}: {e}")
|
|
145
|
+
if ajustes is not None:
|
|
146
|
+
afirma("· and the settings value is still JSON afterwards", True, "")
|
|
147
|
+
comprueba("· with the cross-session path closed",
|
|
148
|
+
ajustes.get("crossSessionInbound"), "refuse")
|
|
149
|
+
afirma("· and every declared settings key inside it",
|
|
150
|
+
all(t["clave"] in ajustes
|
|
151
|
+
for t in arnes.declaracion()["claude"]["trato"]
|
|
152
|
+
if t.get("rinde") == "settings"),
|
|
153
|
+
str(ajustes))
|
|
154
|
+
# A real shell, not just a parser: brace expansion is the half `shlex`
|
|
155
|
+
# forgives, and it is the half that broke.
|
|
156
|
+
import subprocess # noqa: PLC0415
|
|
157
|
+
|
|
158
|
+
r = subprocess.run(
|
|
159
|
+
["bash", "-c", 'set -- ' + arnes.banderas("claude") + '; printf "%s\n" "$@"'],
|
|
160
|
+
capture_output=True, text=True,
|
|
161
|
+
)
|
|
162
|
+
entregado = [l for l in r.stdout.split("\n") if l]
|
|
163
|
+
comprueba("· a real shell hands over exactly four words", len(entregado), 4)
|
|
164
|
+
try:
|
|
165
|
+
json.loads(entregado[1])
|
|
166
|
+
afirma("· and the second is the settings, intact", True, "")
|
|
167
|
+
except json.JSONDecodeError as e:
|
|
168
|
+
afirma("· and the second is the settings, intact", False, f"{entregado!r}: {e}")
|
|
169
|
+
|
|
122
170
|
|
|
123
171
|
def _es_metodo_declarado(aguja, declaradas):
|
|
124
172
|
"""`approvalPolicy` declared, read through a method of the same name."""
|
|
125
173
|
return any(aguja.lower() == d.lower() for d in declaradas)
|
|
126
174
|
|
|
127
175
|
|
|
176
|
+
def valores_que_no_sobrevivirian(tmp):
|
|
177
|
+
"""Values that a shell would eat, or run.
|
|
178
|
+
|
|
179
|
+
The happy checks above prove today's declaration survives. They cannot
|
|
180
|
+
prove the NEXT one will: the values there are mild strings, and the bug
|
|
181
|
+
that broke every Claude window was a comma. The day somebody declares a
|
|
182
|
+
value with a space in it, or a quote, this has to hold — and if it does
|
|
183
|
+
not, it must fail here rather than in a person's terminal.
|
|
184
|
+
|
|
185
|
+
The last two are not a formatting concern. `$(...)` and backticks in an
|
|
186
|
+
unquoted argument are executed by the shell, and this declaration is read
|
|
187
|
+
from a file: a value that runs a command is a value that runs somebody
|
|
188
|
+
else's command.
|
|
189
|
+
"""
|
|
190
|
+
print(" values a shell would eat, or run")
|
|
191
|
+
testigo = os.path.join(tmp, "ejecutado")
|
|
192
|
+
hostiles = {
|
|
193
|
+
"conEspacio": "dos palabras",
|
|
194
|
+
"conComillas": 'dice "hola" y \'adios\'',
|
|
195
|
+
"conLlaves": "{a,b}",
|
|
196
|
+
"conPuntoYComa": "uno; echo dos",
|
|
197
|
+
"conDolar": "$HOME y ${OTRO}",
|
|
198
|
+
"conSustitucion": f"$(touch {testigo})",
|
|
199
|
+
"conAcentoGrave": f"`touch {testigo}`",
|
|
200
|
+
}
|
|
201
|
+
real = arnes.declaracion
|
|
202
|
+
arnes.declaracion = lambda: {
|
|
203
|
+
"claude": {
|
|
204
|
+
"trato": [
|
|
205
|
+
{"clave": k, "valor": v, "via": "x", "porque": "y" * 30, "rinde": "settings"}
|
|
206
|
+
for k, v in hostiles.items()
|
|
207
|
+
]
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
try:
|
|
211
|
+
linea = arnes.banderas("claude")
|
|
212
|
+
finally:
|
|
213
|
+
arnes.declaracion = real
|
|
214
|
+
|
|
215
|
+
# A real shell, because `shlex` forgives what bash does not.
|
|
216
|
+
r = subprocess.run(
|
|
217
|
+
["bash", "-c", "set -- " + linea + '; printf "%s\\n" "$@"'],
|
|
218
|
+
capture_output=True, text=True,
|
|
219
|
+
)
|
|
220
|
+
entregado = [l for l in r.stdout.split("\n") if l]
|
|
221
|
+
afirma("· a hostile declaration still comes out as two words",
|
|
222
|
+
len(entregado) == 2 and entregado[0] == "--settings",
|
|
223
|
+
f"{entregado!r} from {linea!r}")
|
|
224
|
+
afirma("· nothing in it was executed on the way",
|
|
225
|
+
not os.path.exists(testigo),
|
|
226
|
+
f"{testigo} exists: a declared value ran a command")
|
|
227
|
+
try:
|
|
228
|
+
vuelta = json.loads(entregado[1]) if len(entregado) > 1 else {}
|
|
229
|
+
except json.JSONDecodeError as e:
|
|
230
|
+
vuelta = {}
|
|
231
|
+
afirma("· and it is still JSON on the other side", False, f"{entregado[1]!r}: {e}")
|
|
232
|
+
for clave, valor in hostiles.items():
|
|
233
|
+
comprueba(f"· {clave} arrives exactly as declared", vuelta.get(clave), valor)
|
|
234
|
+
|
|
235
|
+
|
|
128
236
|
def lo_que_hay_en_el_disco():
|
|
129
237
|
print(" it reads the machine, and admits what it cannot read")
|
|
130
238
|
casa = tempfile.mkdtemp()
|
|
@@ -183,6 +291,7 @@ def el_informe():
|
|
|
183
291
|
def main():
|
|
184
292
|
la_declaracion()
|
|
185
293
|
sin_deriva()
|
|
294
|
+
valores_que_no_sobrevivirian(tempfile.mkdtemp())
|
|
186
295
|
lo_que_hay_en_el_disco()
|
|
187
296
|
el_informe()
|
|
188
297
|
return resumen("arnes")
|
package/bin/test-channel.py
CHANGED
|
@@ -22,6 +22,37 @@ CHANNEL = os.path.join(RAIZ, 'plugin', 'channel', 'run.sh')
|
|
|
22
22
|
CLIENT = os.path.join(RAIZ, 'plugin', 'channel', 'client.js')
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
def espera_avisos(bus, quieto=0.6, tope=10):
|
|
26
|
+
"""The seat's wake-ups, once they have arrived AND stopped arriving.
|
|
27
|
+
|
|
28
|
+
Reading `bus.mensajes` at a fixed moment samples a race, and a sample lies
|
|
29
|
+
in both directions: it saw zero on a loaded Linux runner and failed a
|
|
30
|
+
working product, and on a slow one it could see two of a hundred and pass a
|
|
31
|
+
broken one. Both halves of the assertion need the burst to be over — the
|
|
32
|
+
lower bound needs them to have arrived, the upper bound needs no more to be
|
|
33
|
+
coming.
|
|
34
|
+
|
|
35
|
+
So: wait for the first, then wait until none has appeared for `quieto`.
|
|
36
|
+
Quiescence, not a guess at how long a hundred arrivals take.
|
|
37
|
+
"""
|
|
38
|
+
def cuantos():
|
|
39
|
+
return sum(1 for m in bus.mensajes
|
|
40
|
+
if m.get('method') == 'notifications/claude/channel')
|
|
41
|
+
|
|
42
|
+
limite = time.monotonic() + tope
|
|
43
|
+
bus.espera(lambda m: m.get('method') == 'notifications/claude/channel', segundos=tope)
|
|
44
|
+
ultimo, estable = cuantos(), time.monotonic()
|
|
45
|
+
while time.monotonic() < limite:
|
|
46
|
+
time.sleep(0.05)
|
|
47
|
+
ahora = cuantos()
|
|
48
|
+
if ahora != ultimo:
|
|
49
|
+
ultimo, estable = ahora, time.monotonic()
|
|
50
|
+
elif time.monotonic() - estable >= quieto:
|
|
51
|
+
break
|
|
52
|
+
return [m for m in bus.mensajes
|
|
53
|
+
if m.get('method') == 'notifications/claude/channel']
|
|
54
|
+
|
|
55
|
+
|
|
25
56
|
def espera(condicion, segundos=8):
|
|
26
57
|
limite = time.monotonic() + segundos
|
|
27
58
|
while time.monotonic() < limite:
|
|
@@ -398,8 +429,9 @@ def main():
|
|
|
398
429
|
'alice/home' in texto(roster)
|
|
399
430
|
and 'alice/ghost' not in texto(roster)
|
|
400
431
|
and '"online": true' in texto(roster), texto(roster))
|
|
401
|
-
|
|
402
|
-
|
|
432
|
+
# Waited for, not sampled. Reading the list at a fixed moment saw zero
|
|
433
|
+
# on a loaded runner and failed a product that was working.
|
|
434
|
+
road_notices = espera_avisos(b)
|
|
403
435
|
# Coalesced, not exactly-one. The property is that a hundred arrivals
|
|
404
436
|
# do not become a hundred interruptions; whether the window happens to
|
|
405
437
|
# close once or twice mid-burst is the machine's business, and asserting
|