agents-city 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.es.md +240 -11
- package/README.md +238 -11
- package/benchmarks/latency/fake-claude-cli.mjs +2 -1
- package/benchmarks/reception/README.md +16 -0
- package/benchmarks/reception/run.mjs +84 -0
- package/bin/agents-city.js +1 -0
- package/bin/connect +5 -0
- package/bin/hall.html +117 -18
- package/bin/navegador.mjs +62 -1
- package/bin/serve.py +106 -97
- package/bin/test +14 -3
- package/bin/test-arnes.py +192 -0
- package/bin/test-cage.py +9 -0
- package/bin/test-channel.py +178 -12
- package/bin/test-claude-runtime.py +132 -4
- package/bin/test-connect-client.mjs +609 -0
- package/bin/test-connect.py +52 -0
- package/bin/test-contracts.py +214 -0
- package/bin/test-doctor.py +31 -0
- package/bin/test-i18n.py +189 -0
- package/bin/test-navegador.py +52 -0
- package/bin/test-seat.py +78 -20
- package/bin/test-security.py +2 -0
- package/bin/test-serve.py +292 -0
- package/city/web/dist-hall/hall.js +733 -209
- package/city/web/src/bienvenida.ts +14 -13
- package/city/web/src/casa.ts +98 -56
- package/city/web/src/demo.ts +61 -31
- package/city/web/src/es.ts +187 -27
- package/city/web/src/explorador.ts +70 -43
- package/city/web/src/hall.ts +468 -98
- package/city/web/src/idioma.ts +21 -1
- package/city/web/src/motores.ts +48 -1
- package/city/web/src/vista.ts +58 -0
- package/demo/graba.py +5 -10
- package/docs/managed-connect.md +237 -0
- package/docs/security.md +41 -2
- package/package.json +8 -4
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/channel/adapter-prompts.ts +10 -0
- package/plugin/channel/adapter.js +78 -31
- package/plugin/channel/agents_city_hybrid_crypto_bg.wasm +0 -0
- package/plugin/channel/bus.js +109 -65
- package/plugin/channel/bus.ts +11 -1
- package/plugin/channel/client.js +67 -30
- package/plugin/channel/delivery-queue.ts +160 -21
- package/plugin/channel/hub/remote-roads.ts +32 -1
- package/plugin/channel/hub/road-controller.ts +116 -16
- package/plugin/channel/kinsh_vodozemac_wasm_bg.wasm +0 -0
- package/plugin/channel/licenses/CONNECT_CLIENT_THIRD_PARTY_NOTICES.md +21 -0
- package/plugin/channel/licenses/HYBRID_CRYPTO_THIRD_PARTY_NOTICES.md +12 -0
- package/plugin/channel/licenses/hybrid-crypto-Apache-2.0.txt +201 -0
- package/plugin/channel/licenses/keyring-MIT.txt +21 -0
- package/plugin/channel/licenses/vodozemac-Apache-2.0.txt +201 -0
- package/plugin/channel/local-hub.js +2080 -97
- package/plugin/channel/local-hub.ts +5 -4
- package/plugin/channel/managed-connect/bridge.ts +208 -0
- package/plugin/channel/managed-connect/cli.ts +416 -0
- package/plugin/channel/managed-connect/device.ts +15 -0
- package/plugin/channel/managed-connect/local-cities.ts +94 -0
- package/plugin/channel/managed-connect/person-message.ts +74 -0
- package/plugin/channel/managed-connect/reception-bridge.ts +341 -0
- package/plugin/channel/managed-connect/relay-session.ts +7 -0
- package/plugin/channel/managed-connect/storage.ts +653 -0
- package/plugin/channel/managed-connect/transport.ts +87 -0
- package/plugin/channel/managed-connect-cli.js +4777 -0
- package/plugin/channel/managed-connect-cli.ts +7 -0
- package/plugin/channel/managed-connect-client.d.ts +261 -0
- package/plugin/channel/managed-connect-client.js +6572 -0
- package/plugin/channel/managed-connect-client.manifest.json +38 -0
- package/plugin/channel/package-lock.json +123 -105
- package/plugin/channel/package.json +4 -4
- package/plugin/channel/protocol.ts +4 -0
- package/plugin/channel/reception.ts +896 -0
- package/plugin/channel/road-cli.ts +1 -1
- package/plugin/channel/runtime/arnes.json +189 -0
- package/plugin/channel/runtime/arnes.ts +47 -0
- package/plugin/channel/runtime/claude.ts +18 -0
- package/plugin/channel/runtime/codex-config.ts +51 -1
- package/plugin/channel/runtime/codex.ts +31 -11
- package/plugin/channel/runtime/kimi.ts +6 -4
- package/plugin/channel/runtime-files.ts +39 -5
- package/plugin/channel/runtime-gateway.js +426 -100
- package/plugin/channel/runtime-gateway.ts +12 -0
- package/plugin/channel/trust/agents-city-sandbox-roots.json +66 -0
- package/plugin/scripts/arnes.py +271 -0
- package/plugin/scripts/busca.py +46 -10
- package/plugin/scripts/cage.py +5 -0
- package/plugin/scripts/city-session.sh +144 -28
- package/plugin/scripts/crecimiento.py +4 -1
- package/plugin/scripts/demos.py +58 -43
- package/plugin/scripts/desinstala.py +36 -21
- package/plugin/scripts/doctor.py +37 -15
- package/plugin/scripts/read-card.py +48 -8
- package/plugin/scripts/reception.py +665 -0
package/bin/serve.py
CHANGED
|
@@ -23,7 +23,9 @@ import re
|
|
|
23
23
|
import secrets
|
|
24
24
|
import shutil
|
|
25
25
|
import signal
|
|
26
|
+
import sqlite3
|
|
26
27
|
import socketserver
|
|
28
|
+
import stat
|
|
27
29
|
import subprocess
|
|
28
30
|
import sys
|
|
29
31
|
import threading
|
|
@@ -40,9 +42,11 @@ import domains # noqa: E402
|
|
|
40
42
|
import gh # noqa: E402
|
|
41
43
|
import parcels # noqa: E402
|
|
42
44
|
import units # noqa: E402
|
|
45
|
+
import busca # noqa: E402 the disk scanner, and the one list of where work lives
|
|
43
46
|
import cities # noqa: E402
|
|
44
47
|
import demos # noqa: E402 the recorded demos the Hall plays back
|
|
45
48
|
import roads # noqa: E402
|
|
49
|
+
import reception # noqa: E402
|
|
46
50
|
import capabilities # noqa: E402
|
|
47
51
|
import deliberations # noqa: E402
|
|
48
52
|
import avatar # noqa: E402
|
|
@@ -187,6 +191,22 @@ def estado_seguro_agentes(datos):
|
|
|
187
191
|
return []
|
|
188
192
|
|
|
189
193
|
|
|
194
|
+
def resumen_recepcion_segura():
|
|
195
|
+
"""A damaged optional inbox must not make the whole local Hall disappear."""
|
|
196
|
+
try:
|
|
197
|
+
return reception.resumen()
|
|
198
|
+
except (OSError, sqlite3.Error, reception.ReceptionError) as e:
|
|
199
|
+
return {
|
|
200
|
+
"pending": 0,
|
|
201
|
+
"pendingBytes": 0,
|
|
202
|
+
"routingMode": "manual",
|
|
203
|
+
"reviewPolicy": "every_message",
|
|
204
|
+
"routerProfile": None,
|
|
205
|
+
"autoAvailable": False,
|
|
206
|
+
"error": str(e),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
190
210
|
def olvida_skills():
|
|
191
211
|
"""Forget the discovery memo: a skill was installed or removed, and that
|
|
192
212
|
changes the answer without touching the card."""
|
|
@@ -401,24 +421,6 @@ def historia_del_demo(datos):
|
|
|
401
421
|
historia = lee_clave(os.path.join(datos, "city.yml"), "domain")
|
|
402
422
|
return historia if historia in DEMO_STORIES else "software"
|
|
403
423
|
|
|
404
|
-
# Where people actually keep their code. The curses wizard offered `~/code` and
|
|
405
|
-
# nothing else, which is wrong on most machines and silently scans a folder that
|
|
406
|
-
# is not there. Offer the ones that exist, most-repos first, and let them type.
|
|
407
|
-
CANDIDATAS = [
|
|
408
|
-
"code",
|
|
409
|
-
"codigo",
|
|
410
|
-
"src",
|
|
411
|
-
"dev",
|
|
412
|
-
"devel",
|
|
413
|
-
"projects",
|
|
414
|
-
"proyectos",
|
|
415
|
-
"work",
|
|
416
|
-
"trabajo",
|
|
417
|
-
"repos",
|
|
418
|
-
"git",
|
|
419
|
-
"Developer",
|
|
420
|
-
"Documents",
|
|
421
|
-
]
|
|
422
424
|
|
|
423
425
|
|
|
424
426
|
def glob_fichas(datos):
|
|
@@ -524,30 +526,16 @@ def actividad_viva(datos):
|
|
|
524
526
|
def _que_es_git(ruta):
|
|
525
527
|
"""`repo`, `worktree`, or nothing. A label on a row, never a filter: the
|
|
526
528
|
picker shows every folder and lets the person decide which one matters."""
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
529
|
+
try:
|
|
530
|
+
# One stat, not an isdir followed by an isfile: a clone's `.git` is a
|
|
531
|
+
# directory and a linked worktree's is a file, and that is the whole
|
|
532
|
+
# question.
|
|
533
|
+
modo = os.stat(os.path.join(ruta, ".git")).st_mode
|
|
534
|
+
except OSError:
|
|
535
|
+
return ""
|
|
536
|
+
return "repo" if stat.S_ISDIR(modo) else "worktree"
|
|
533
537
|
|
|
534
538
|
|
|
535
|
-
def carpetas_probables():
|
|
536
|
-
"""Existing folders under $HOME that look like where the work lives."""
|
|
537
|
-
casa = os.path.expanduser("~")
|
|
538
|
-
fuera = []
|
|
539
|
-
for n in CANDIDATAS:
|
|
540
|
-
ruta = os.path.join(casa, n)
|
|
541
|
-
if not os.path.isdir(ruta):
|
|
542
|
-
continue
|
|
543
|
-
try:
|
|
544
|
-
hijos = [x for x in os.listdir(ruta) if not x.startswith(".")]
|
|
545
|
-
gits = sum(1 for x in hijos if os.path.isdir(os.path.join(ruta, x, ".git")))
|
|
546
|
-
except OSError:
|
|
547
|
-
continue
|
|
548
|
-
fuera.append({"ruta": ruta, "nombre": n, "items": len(hijos), "gits": gits})
|
|
549
|
-
fuera.sort(key=lambda c: (-c["gits"], -c["items"]))
|
|
550
|
-
return fuera
|
|
551
539
|
|
|
552
540
|
|
|
553
541
|
def catalogo_roles(dominio):
|
|
@@ -705,6 +693,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
705
693
|
"/api/demos": "g_demos",
|
|
706
694
|
"/api/domains": "g_domains",
|
|
707
695
|
"/api/roles": "g_roles",
|
|
696
|
+
"/api/reception": "g_reception",
|
|
708
697
|
}
|
|
709
698
|
POSTS = {
|
|
710
699
|
"/api/ficha": "p_ficha",
|
|
@@ -716,6 +705,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
716
705
|
"/api/ciudad-archiva": "p_archiva_ciudad",
|
|
717
706
|
"/api/ciudad-reinicia": "p_reinicia_ciudad",
|
|
718
707
|
"/api/roads": "p_roads",
|
|
708
|
+
"/api/reception": "p_reception",
|
|
719
709
|
"/api/agente": "p_agente",
|
|
720
710
|
"/api/agentes": "p_agentes",
|
|
721
711
|
"/api/montaje": "p_montaje",
|
|
@@ -837,6 +827,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
837
827
|
# Registration is earned by writing to one, never by looking.
|
|
838
828
|
"ciudades": lista_con(datos),
|
|
839
829
|
"roads": roads.lee(datos),
|
|
830
|
+
"reception": resumen_recepcion_segura(),
|
|
840
831
|
"invitation": roads.invitacion(datos, owner),
|
|
841
832
|
"skills": habilidades,
|
|
842
833
|
"deliberations": deliberations.lista(datos),
|
|
@@ -855,6 +846,13 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
855
846
|
def g_live(self, q):
|
|
856
847
|
return self.responde(actividad_viva(self.ciudad(q)))
|
|
857
848
|
|
|
849
|
+
def g_reception(self, q):
|
|
850
|
+
"""Owner-level remote quarantine; it is deliberately not city-scoped."""
|
|
851
|
+
try:
|
|
852
|
+
return self.responde(reception.estado(seat.quien_soy(), actual=self.ciudad(q)))
|
|
853
|
+
except (OSError, sqlite3.Error, reception.ReceptionError) as e:
|
|
854
|
+
return self.responde({"error": str(e)}, codigo=503)
|
|
855
|
+
|
|
858
856
|
def g_demos(self, q):
|
|
859
857
|
"""The recorded demos, and one of them in full when asked by name.
|
|
860
858
|
|
|
@@ -864,18 +862,14 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
864
862
|
the browser owning a second, drifting copy of the state machine.
|
|
865
863
|
"""
|
|
866
864
|
cual = q.get("story", [""])[0]
|
|
867
|
-
catalogo = demos.catalogo()
|
|
868
865
|
if not cual:
|
|
869
|
-
return self.responde({"demos": catalogo})
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
return self.responde({"error": "no such demo"}, codigo=404)
|
|
866
|
+
return self.responde({"demos": demos.catalogo()})
|
|
867
|
+
# One read of one file: the card is derived from the very events being
|
|
868
|
+
# served, rather than building the whole shelf to look one row up.
|
|
873
869
|
eventos = demos.eventos(cual)
|
|
874
870
|
if not eventos:
|
|
875
|
-
return self.responde(
|
|
876
|
-
|
|
877
|
-
)
|
|
878
|
-
return self.responde({**historia, "eventos": eventos})
|
|
871
|
+
return self.responde({"error": "no such demo"}, codigo=404)
|
|
872
|
+
return self.responde({**(demos.ficha(cual, eventos) or {}), "eventos": eventos})
|
|
879
873
|
|
|
880
874
|
def g_carpeta(self, q):
|
|
881
875
|
"""One folder, listed: what is in it, and what each thing is.
|
|
@@ -911,10 +905,6 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
911
905
|
"nombre": e.name,
|
|
912
906
|
"ruta": os.path.join(ruta, e.name),
|
|
913
907
|
"dir": es_dir,
|
|
914
|
-
# What kind of place this is, said plainly rather
|
|
915
|
-
# than used to filter anything out: a folder is
|
|
916
|
-
# yours to pick whether or not it holds a .git.
|
|
917
|
-
"git": _que_es_git(os.path.join(ruta, e.name)) if es_dir else "",
|
|
918
908
|
"enlace": e.is_symlink(),
|
|
919
909
|
}
|
|
920
910
|
)
|
|
@@ -923,40 +913,31 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
923
913
|
# Folders first, then files, each alphabetical and case-insensitive —
|
|
924
914
|
# the order every file picker has used for forty years.
|
|
925
915
|
entradas.sort(key=lambda x: (not x["dir"], x["nombre"].lower()))
|
|
916
|
+
recortada = len(entradas) > 2000
|
|
917
|
+
entradas = entradas[:2000]
|
|
918
|
+
# Only now, and only for the rows that will be sent. Asking every entry
|
|
919
|
+
# in a twenty-thousand-item directory whether it holds a `.git` was
|
|
920
|
+
# eighteen thousand pairs of stat calls for rows nobody would see. What
|
|
921
|
+
# kind of place this is gets said plainly rather than used to filter
|
|
922
|
+
# anything out: a folder is yours to pick whether or not it holds one.
|
|
923
|
+
for e in entradas:
|
|
924
|
+
e["git"] = _que_es_git(e["ruta"]) if e["dir"] else ""
|
|
926
925
|
casa = os.path.expanduser("~")
|
|
927
926
|
arriba = os.path.dirname(ruta)
|
|
928
927
|
return self.responde(
|
|
929
928
|
{
|
|
930
929
|
"ruta": ruta,
|
|
931
930
|
"arriba": arriba if arriba != ruta else "",
|
|
932
|
-
"casa": casa,
|
|
933
931
|
"atajos": [
|
|
934
932
|
{"nombre": os.path.basename(c) or c, "ruta": c}
|
|
935
|
-
for c in [casa] + [os.path.join(casa, n) for n in CANDIDATAS]
|
|
933
|
+
for c in [casa] + [os.path.join(casa, n) for n in busca.CANDIDATAS]
|
|
936
934
|
if os.path.isdir(c)
|
|
937
935
|
],
|
|
938
|
-
"entradas": entradas
|
|
939
|
-
"recortada":
|
|
936
|
+
"entradas": entradas,
|
|
937
|
+
"recortada": recortada,
|
|
940
938
|
}
|
|
941
939
|
)
|
|
942
940
|
|
|
943
|
-
def g_arranque(self, q):
|
|
944
|
-
plants = W.plantillas()
|
|
945
|
-
return self.responde(
|
|
946
|
-
{
|
|
947
|
-
"kinds": plants,
|
|
948
|
-
"destino": DESTINO,
|
|
949
|
-
"casa": os.path.expanduser("~"),
|
|
950
|
-
"carpetas": carpetas_probables(),
|
|
951
|
-
# Only whether the CLI is there. Whether it is logged in is the
|
|
952
|
-
# orgs endpoint's answer, and asking it here would make every
|
|
953
|
-
# page load wait on a network call.
|
|
954
|
-
"gh": bool(shutil.which("gh")),
|
|
955
|
-
"existe": os.path.isdir(DESTINO),
|
|
956
|
-
"paleta": [{"hex": c, "nombre": n} for c, n in W.PALETA],
|
|
957
|
-
"arquitectos": sorted(ARQUITECTOS),
|
|
958
|
-
}
|
|
959
|
-
)
|
|
960
941
|
|
|
961
942
|
def g_roles(self, q):
|
|
962
943
|
if q.get("scope", [""])[0] == "agent":
|
|
@@ -974,29 +955,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
974
955
|
}
|
|
975
956
|
)
|
|
976
957
|
|
|
977
|
-
def g_orgs(self, q):
|
|
978
|
-
return self.responde({"orgs": W.orgs_de_gh()})
|
|
979
958
|
|
|
980
|
-
def g_repos(self, q):
|
|
981
|
-
org = q.get("org", [""])[0]
|
|
982
|
-
if org:
|
|
983
|
-
hallados = [
|
|
984
|
-
{"nombre": n, "detalle": d, "fecha": f, "ruta": ""}
|
|
985
|
-
for n, d, f in W.repos_de_org(org)
|
|
986
|
-
]
|
|
987
|
-
return self.responde({"repos": hallados})
|
|
988
|
-
base = os.path.expanduser(q.get("ruta", [""])[0])
|
|
989
|
-
if not base or not os.path.isdir(base):
|
|
990
|
-
return self.responde({"repos": [], "error": f"{base or '(empty)'} is not a folder"})
|
|
991
|
-
# Two different third columns, and saying so beats guessing in the page:
|
|
992
|
-
# a git repo reports its last commit, any-folder reports what is inside.
|
|
993
|
-
if q.get("modo", ["git"])[0] == "git":
|
|
994
|
-
crudo = W.repos_del_disco(base)
|
|
995
|
-
repos = [{"nombre": n, "ruta": r, "detalle": "", "fecha": f} for n, r, f in crudo]
|
|
996
|
-
else:
|
|
997
|
-
crudo = W.carpetas_del_disco(base)
|
|
998
|
-
repos = [{"nombre": n, "ruta": r, "detalle": d, "fecha": ""} for n, r, d in crudo]
|
|
999
|
-
return self.responde({"repos": repos})
|
|
1000
959
|
|
|
1001
960
|
def g_gente(self, q):
|
|
1002
961
|
org = q.get("org", [""])[0]
|
|
@@ -1242,6 +1201,51 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
1242
1201
|
return self.responde({"error": str(e)}, codigo=400)
|
|
1243
1202
|
return self.responde({"ok": True, "roads": roads.lee(origen)})
|
|
1244
1203
|
|
|
1204
|
+
def p_reception(self, q, cuerpo):
|
|
1205
|
+
"""One human decision, committed before any city can consume the text."""
|
|
1206
|
+
if cuerpo.get("action") == "configure":
|
|
1207
|
+
try:
|
|
1208
|
+
return self.responde(
|
|
1209
|
+
reception.configura(
|
|
1210
|
+
seat.quien_soy(),
|
|
1211
|
+
cuerpo.get("routing_mode"),
|
|
1212
|
+
cuerpo.get("rules"),
|
|
1213
|
+
self.ciudad(q),
|
|
1214
|
+
)
|
|
1215
|
+
)
|
|
1216
|
+
except reception.ReceptionError as e:
|
|
1217
|
+
return self.responde({"error": str(e)}, codigo=400)
|
|
1218
|
+
except (OSError, sqlite3.Error) as e:
|
|
1219
|
+
return self.responde({"error": f"reception unavailable: {e}"}, codigo=503)
|
|
1220
|
+
if cuerpo.get("action") == "send":
|
|
1221
|
+
try:
|
|
1222
|
+
return self.responde(
|
|
1223
|
+
reception.envia(cuerpo.get("connection_id"), cuerpo.get("text")),
|
|
1224
|
+
codigo=202,
|
|
1225
|
+
)
|
|
1226
|
+
except reception.ReceptionError as e:
|
|
1227
|
+
return self.responde({"error": str(e)}, codigo=400)
|
|
1228
|
+
except (OSError, sqlite3.Error) as e:
|
|
1229
|
+
return self.responde({"error": f"reception unavailable: {e}"}, codigo=503)
|
|
1230
|
+
destinos = cuerpo.get("destinations") or []
|
|
1231
|
+
if not isinstance(destinos, list):
|
|
1232
|
+
return self.responde({"error": "destinations must be a list"}, codigo=400)
|
|
1233
|
+
try:
|
|
1234
|
+
resultado = reception.decide(
|
|
1235
|
+
seat.quien_soy(),
|
|
1236
|
+
cuerpo.get("message_id"),
|
|
1237
|
+
cuerpo.get("action"),
|
|
1238
|
+
destinos,
|
|
1239
|
+
cuerpo.get("reason"),
|
|
1240
|
+
self.ciudad(q),
|
|
1241
|
+
)
|
|
1242
|
+
except reception.ReceptionError as e:
|
|
1243
|
+
codigo = 409 if "already" in str(e) or "conflict" in str(e) else 400
|
|
1244
|
+
return self.responde({"error": str(e)}, codigo=codigo)
|
|
1245
|
+
except (OSError, sqlite3.Error) as e:
|
|
1246
|
+
return self.responde({"error": f"reception unavailable: {e}"}, codigo=503)
|
|
1247
|
+
return self.responde(resultado)
|
|
1248
|
+
|
|
1245
1249
|
def p_mapa(self, q, cuerpo):
|
|
1246
1250
|
# Bake and serve the map, detached. First run builds the front end and
|
|
1247
1251
|
# seeds the local D1, so the page polls /api/estado until it is up.
|
|
@@ -1290,7 +1294,12 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
1290
1294
|
# nothing, never a shell fragment". `runtime` maps to `runs.` — the launcher's
|
|
1291
1295
|
# own key — and the web deliberately cannot reach the terminal: fallback.
|
|
1292
1296
|
CAMPOS_DE_AGENTE = {
|
|
1293
|
-
|
|
1297
|
+
# A model name is whatever the CLI in play calls it: `opus`, but also
|
|
1298
|
+
# `gpt-5.6-sol` and OpenCode's `anthropic/claude-sonnet-4`. Slash,
|
|
1299
|
+
# underscore and colon are in; whitespace, quotes and every shell
|
|
1300
|
+
# metacharacter stay out, because this value ends up on a command line.
|
|
1301
|
+
"model": ("model", re.compile(r"[a-z0-9][a-z0-9./:_-]{0,63}"),
|
|
1302
|
+
"that is not a model name"),
|
|
1294
1303
|
"effort": ("effort", frozenset(("low", "medium", "high", "xhigh", "max")),
|
|
1295
1304
|
"effort is low..max or empty"),
|
|
1296
1305
|
"runtime": ("runs", frozenset(("claude",) + seat.OTROS_MOTORES),
|
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 serve seat cities channel 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 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
|
|
@@ -90,16 +90,26 @@ necesarios = ["bin/agents-city.js", "bin/hall", "bin/seat", "bin/city", "bin/dem
|
|
|
90
90
|
"plugin/scripts/workspace.py", "plugin/scripts/avatar.py",
|
|
91
91
|
"plugin/scripts/crecimiento.py",
|
|
92
92
|
"plugin/scripts/reset.py", "plugin/scripts/desinstala.py",
|
|
93
|
-
"plugin/scripts/demos.py", "plugin/scripts/
|
|
93
|
+
"plugin/scripts/demos.py", "plugin/scripts/arnes.py",
|
|
94
|
+
"plugin/channel/runtime/arnes.json", "plugin/scripts/capabilities.py",
|
|
94
95
|
"plugin/scripts/deliberations.py",
|
|
95
96
|
"plugin/scripts/runtime_processes.py",
|
|
96
97
|
"plugin/scripts/runtime_log.py", "plugin/scripts/logs.py",
|
|
97
98
|
"plugin/scripts/launch.py", "plugin/scripts/hook_activity.py",
|
|
99
|
+
"plugin/scripts/reception.py",
|
|
98
100
|
"plugin/scripts/city-session.sh", "plugin/scripts/city-runtime.sh",
|
|
99
101
|
"plugin/scripts/find-repos.sh",
|
|
100
102
|
"plugin/scripts/busca.py",
|
|
101
103
|
"plugin/channel/bus.js", "plugin/channel/local-hub.js",
|
|
102
104
|
"plugin/channel/client.js", "plugin/channel/adapter.js",
|
|
105
|
+
"plugin/channel/managed-connect-client.js",
|
|
106
|
+
"plugin/channel/managed-connect-client.d.ts",
|
|
107
|
+
"plugin/channel/managed-connect-client.manifest.json",
|
|
108
|
+
"plugin/channel/trust/agents-city-sandbox-roots.json",
|
|
109
|
+
"plugin/channel/kinsh_vodozemac_wasm_bg.wasm",
|
|
110
|
+
"plugin/channel/agents_city_hybrid_crypto_bg.wasm",
|
|
111
|
+
"plugin/channel/licenses/CONNECT_CLIENT_THIRD_PARTY_NOTICES.md",
|
|
112
|
+
"plugin/channel/managed-connect-cli.js", "bin/connect",
|
|
103
113
|
"plugin/channel/runtime-gateway.js",
|
|
104
114
|
"plugin/.mcp.json",
|
|
105
115
|
"bin/cities", "bin/road", "bin/bus", "bin/committee", "bin/agents",
|
|
@@ -111,7 +121,8 @@ necesarios = ["bin/agents-city.js", "bin/hall", "bin/seat", "bin/city", "bin/dem
|
|
|
111
121
|
"benchmarks/latency/fake-native-server.mjs",
|
|
112
122
|
"benchmarks/latency/fake-claude-cli.mjs",
|
|
113
123
|
"benchmarks/stress/run.py", "benchmarks/stress/README.md",
|
|
114
|
-
"
|
|
124
|
+
"benchmarks/reception/run.mjs", "benchmarks/reception/README.md",
|
|
125
|
+
"city/web/src/repo-roles.ts", "city/web/src/vista.ts",
|
|
115
126
|
"demo/show.py", "demo/city/city.yml", "demo/city/ada.md",
|
|
116
127
|
"city/scripts/seed.py", "city/worker/schema.sql", "demo/parcels.yml"]
|
|
117
128
|
problemas = [f"missing: {n}" for n in necesarios if n not in dentro]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""What this product does to somebody else's CLI, and whether it says so.
|
|
3
|
+
|
|
4
|
+
The whole pitch is that Agents City orchestrates the CLIs people already run,
|
|
5
|
+
with the plugins, skills, MCP servers and settings they already have. That is a
|
|
6
|
+
claim about someone else's machine, and a claim like that is worth exactly as
|
|
7
|
+
much as the command that checks it.
|
|
8
|
+
|
|
9
|
+
So there are two things to defend here, and the second is the hard one:
|
|
10
|
+
|
|
11
|
+
1. the report reads what is actually on the disk, and never invents a fact
|
|
12
|
+
about a file it could not read;
|
|
13
|
+
2. the report and the RUNTIME cannot drift. Every value the connectors impose
|
|
14
|
+
comes out of `arnes.json`, and nothing is imposed that the declaration does
|
|
15
|
+
not mention. A product that quietly injects an instruction it does not
|
|
16
|
+
print is exactly what this file exists to catch — and it caught one while
|
|
17
|
+
it was being written.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
|
|
26
|
+
AQUI = os.path.dirname(os.path.abspath(__file__))
|
|
27
|
+
RAIZ = os.path.dirname(AQUI)
|
|
28
|
+
sys.path.insert(0, AQUI)
|
|
29
|
+
sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
|
|
30
|
+
|
|
31
|
+
import arnes # noqa: E402
|
|
32
|
+
from testlib import afirma, comprueba, resumen # noqa: E402
|
|
33
|
+
|
|
34
|
+
RUNTIME = os.path.join(RAIZ, "plugin", "channel", "runtime")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def la_declaracion():
|
|
38
|
+
print(" the declaration")
|
|
39
|
+
motores = arnes.declaracion()
|
|
40
|
+
afirma("· every runtime the product launches is declared",
|
|
41
|
+
sorted(motores) == ["claude", "codex", "kimi", "opencode"], str(sorted(motores)))
|
|
42
|
+
for nombre, motor in motores.items():
|
|
43
|
+
for clave in ("binario", "config", "trato", "hereda", "respeta"):
|
|
44
|
+
afirma(f"· {nombre} declares {clave}", clave in motor, str(sorted(motor)))
|
|
45
|
+
for t in motor["trato"]:
|
|
46
|
+
afirma(f"· {nombre}/{t['clave']} says how it is applied", bool(t.get("via")), str(t))
|
|
47
|
+
# The reason is not decoration. Somebody reading this report is
|
|
48
|
+
# deciding whether to trust the product with their machine, and
|
|
49
|
+
# "because we do" is not an answer they can weigh.
|
|
50
|
+
afirma(f"· {nombre}/{t['clave']} says why", len(t.get("porque", "")) > 25, str(t))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sin_deriva():
|
|
54
|
+
print(" the report cannot drift from the behaviour")
|
|
55
|
+
# Comments are stripped first. A check that cannot tell a value from a
|
|
56
|
+
# sentence about that value teaches people to write worse comments, and the
|
|
57
|
+
# comments in these files are load-bearing.
|
|
58
|
+
def codigo(nombre):
|
|
59
|
+
texto = open(os.path.join(RUNTIME, f"{nombre}.ts"), encoding="utf-8").read()
|
|
60
|
+
texto = re.sub(r"/\*.*?\*/", "", texto, flags=re.S)
|
|
61
|
+
return "\n".join(l for l in texto.split("\n") if not l.strip().startswith("//"))
|
|
62
|
+
|
|
63
|
+
fuentes = {n: codigo(n) for n in ("codex", "kimi", "opencode", "claude")}
|
|
64
|
+
# Claude's deal is applied by the shell launcher, not by claude.ts. Scanning
|
|
65
|
+
# only the connectors meant this guard passed VACUOUSLY for the one runtime
|
|
66
|
+
# whose values were respelled somewhere else — the exact failure it exists
|
|
67
|
+
# to catch, hiding in the place it could not see.
|
|
68
|
+
fuentes["claude"] += "\n" + open(
|
|
69
|
+
os.path.join(RAIZ, "plugin", "scripts", "city-session.sh"), encoding="utf-8"
|
|
70
|
+
).read()
|
|
71
|
+
motores = arnes.declaracion()
|
|
72
|
+
|
|
73
|
+
# Every declared value is READ from the declaration, never respelled.
|
|
74
|
+
for nombre, motor in motores.items():
|
|
75
|
+
for t in motor["trato"]:
|
|
76
|
+
valor = t["valor"]
|
|
77
|
+
if len(valor) < 12:
|
|
78
|
+
continue # a short literal like "auto" is not a drift risk
|
|
79
|
+
afirma(
|
|
80
|
+
f"· {nombre}/{t['clave']} is not spelled a second time in {nombre}.ts",
|
|
81
|
+
valor not in fuentes[nombre], f"{valor[:60]}… appears inline",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# And nothing is imposed that the declaration does not mention. These are
|
|
85
|
+
# the shapes a runtime uses to put words or policy into somebody's agent.
|
|
86
|
+
sospechosas = ("developerInstructions", "system_prompt", "systemPrompt",
|
|
87
|
+
"approvalPolicy", "permission_mode", "instructions")
|
|
88
|
+
for nombre, fuente in fuentes.items():
|
|
89
|
+
declaradas = {t["clave"] for t in motores.get(nombre, {}).get("trato", [])}
|
|
90
|
+
# A private method named after the key is how a declared value is read.
|
|
91
|
+
declaradas |= {c[0].lower() + c[1:] for c in declaradas}
|
|
92
|
+
for aguja in sospechosas:
|
|
93
|
+
if not re.search(rf"\b{aguja}\b", fuente):
|
|
94
|
+
continue
|
|
95
|
+
afirma(
|
|
96
|
+
f"· {nombre}.ts sets {aguja}, and the declaration says so",
|
|
97
|
+
aguja in declaradas or _es_metodo_declarado(aguja, declaradas),
|
|
98
|
+
f"{aguja} is imposed by {nombre}.ts and missing from arnes.json",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# The one sentence the product puts into other people's agents lives in
|
|
102
|
+
# exactly one file. It used to live in two, and one of them was undeclared.
|
|
103
|
+
frase = "You are one member of an Agents City committee"
|
|
104
|
+
fuera = [n for n, f in fuentes.items() if frase in f]
|
|
105
|
+
afirma("· the committee instruction is written in one place only",
|
|
106
|
+
not fuera, f"also inline in: {fuera}")
|
|
107
|
+
|
|
108
|
+
# And the launcher asks for the deal rather than carrying a copy of it.
|
|
109
|
+
shell = open(os.path.join(RAIZ, "plugin", "scripts", "city-session.sh"),
|
|
110
|
+
encoding="utf-8").read()
|
|
111
|
+
afirma("· the launcher asks arnes.py for Claude's flags",
|
|
112
|
+
'arnes.py" flags claude' in shell, "")
|
|
113
|
+
banderas = arnes.banderas("claude")
|
|
114
|
+
for trozo in ("--settings", "crossSessionInbound", "--disallowed-tools",
|
|
115
|
+
"SendMessage,ListAgents"):
|
|
116
|
+
afirma(f"· and they are built from the declaration: {trozo}",
|
|
117
|
+
trozo in banderas, banderas)
|
|
118
|
+
afirma("· a runtime whose deal is not command-line shaped gets no flags",
|
|
119
|
+
arnes.banderas("codex") == "" and arnes.banderas("opencode") == "",
|
|
120
|
+
f"codex={arnes.banderas('codex')!r}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _es_metodo_declarado(aguja, declaradas):
|
|
124
|
+
"""`approvalPolicy` declared, read through a method of the same name."""
|
|
125
|
+
return any(aguja.lower() == d.lower() for d in declaradas)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def lo_que_hay_en_el_disco():
|
|
129
|
+
print(" it reads the machine, and admits what it cannot read")
|
|
130
|
+
casa = tempfile.mkdtemp()
|
|
131
|
+
toml = os.path.join(casa, "config.toml")
|
|
132
|
+
open(toml, "w", encoding="utf-8").write(
|
|
133
|
+
'# a comment\n'
|
|
134
|
+
'model = "gpt-5.6-sol"\n'
|
|
135
|
+
"model_reasoning_effort = 'max'\n"
|
|
136
|
+
"suelto = 3\n"
|
|
137
|
+
"[features]\n"
|
|
138
|
+
'oculto = "no debe leerse"\n'
|
|
139
|
+
)
|
|
140
|
+
plano = arnes._lee_toml_plano(toml)
|
|
141
|
+
comprueba("· a quoted value is read", plano.get("model"), "gpt-5.6-sol")
|
|
142
|
+
comprueba("· single quotes too", plano.get("model_reasoning_effort"), "max")
|
|
143
|
+
comprueba("· and a bare one", plano.get("suelto"), "3")
|
|
144
|
+
afirma("· a comment is not a setting", "# a comment" not in plano, str(plano))
|
|
145
|
+
afirma("· and it stops at the first section rather than guessing",
|
|
146
|
+
"oculto" not in plano, str(plano))
|
|
147
|
+
comprueba("· a file that is not there is empty, not an error",
|
|
148
|
+
arnes._lee_toml_plano(os.path.join(casa, "no-hay")), {})
|
|
149
|
+
|
|
150
|
+
print(" it never invents a value")
|
|
151
|
+
afirma("· a long block is counted, not dumped",
|
|
152
|
+
arnes.resume({"allow": [1, 2, 3], "deny": []}) == "3 entries",
|
|
153
|
+
arnes.resume({"allow": [1, 2, 3], "deny": []}))
|
|
154
|
+
afirma("· a long string is cut, and says it was",
|
|
155
|
+
arnes.resume("x" * 200).endswith("…"), arnes.resume("x" * 200))
|
|
156
|
+
comprueba("· a short one is left alone", arnes.resume("auto"), "auto")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def el_informe():
|
|
160
|
+
print(" the report")
|
|
161
|
+
entradas = arnes.informe(instalado=lambda _b: False)
|
|
162
|
+
comprueba("· one entry per runtime", len(entradas), 4)
|
|
163
|
+
afirma("· a CLI that is not on this machine is said to be missing",
|
|
164
|
+
all(not e["instalado"] for e in entradas), "")
|
|
165
|
+
salida = []
|
|
166
|
+
arnes.imprime(entradas, di=salida.append)
|
|
167
|
+
texto = "\n".join(salida)
|
|
168
|
+
afirma("· it separates the deal from what it inherits and what it leaves alone",
|
|
169
|
+
"the deal" in texto and "we inherit" in texto and "untouched" in texto, texto[:400])
|
|
170
|
+
afirma("· it says the deal is what makes the bus the only route",
|
|
171
|
+
"the only route" in texto, texto[-400:])
|
|
172
|
+
afirma("· and how to switch the cage off",
|
|
173
|
+
"CITY_CAGE=0" in texto, texto[-400:])
|
|
174
|
+
afirma("· a runtime that imposes nothing says so plainly",
|
|
175
|
+
"nothing — it runs exactly as you configured it" in texto, texto)
|
|
176
|
+
# It is a report about somebody's machine: it must survive not finding one.
|
|
177
|
+
vacio = arnes.informe(instalado=lambda _b: True)
|
|
178
|
+
afirma("· and it renders on a machine with no configuration at all",
|
|
179
|
+
len(vacio) == 4, str(len(vacio)))
|
|
180
|
+
json.dumps(entradas) # the --json door must stay serialisable
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def main():
|
|
184
|
+
la_declaracion()
|
|
185
|
+
sin_deriva()
|
|
186
|
+
lo_que_hay_en_el_disco()
|
|
187
|
+
el_informe()
|
|
188
|
+
return resumen("arnes")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
sys.exit(main())
|
package/bin/test-cage.py
CHANGED
|
@@ -66,6 +66,8 @@ def texto_del_perfil():
|
|
|
66
66
|
"channels" in p and "\\.env" in p)
|
|
67
67
|
afirma("the broker state dir is sealed",
|
|
68
68
|
os.path.join(casa, ".agents-city", ".runtime", "broker") in p)
|
|
69
|
+
afirma("managed device keys are sealed",
|
|
70
|
+
os.path.join(casa, ".agents-city", ".runtime", "connect") in p)
|
|
69
71
|
afirma("~/.npmrc stays readable on purpose (a broken npm protects nobody)",
|
|
70
72
|
os.path.join(casa, ".npmrc") not in p)
|
|
71
73
|
finally:
|
|
@@ -129,6 +131,8 @@ def excepciones_y_errores():
|
|
|
129
131
|
p = cage.perfil(repo, casa=casa)
|
|
130
132
|
afirma("the broker seal follows a relocated AGENTS_CITY_HOME",
|
|
131
133
|
os.path.join(os.path.realpath(relocado), ".runtime", "broker") in p)
|
|
134
|
+
afirma("the managed-key seal follows a relocated AGENTS_CITY_HOME",
|
|
135
|
+
os.path.join(os.path.realpath(relocado), ".runtime", "connect") in p)
|
|
132
136
|
afirma("and the relocated home stays writable",
|
|
133
137
|
f'(subpath "{os.path.realpath(relocado)}")' in p)
|
|
134
138
|
finally:
|
|
@@ -234,6 +238,11 @@ def argv_de_linux():
|
|
|
234
238
|
i_repo < i_ssh, f"repo at {i_repo}, sealed .ssh at {i_ssh}")
|
|
235
239
|
afirma("a sealed directory becomes an empty tmpfs, not a refusal",
|
|
236
240
|
f"--tmpfs {real(os.path.join(casa, '.ssh'))}" in texto, texto[-400:])
|
|
241
|
+
connect = real(os.path.join(casa, '.agents-city', '.runtime', 'connect'))
|
|
242
|
+
os.makedirs(connect, exist_ok=True)
|
|
243
|
+
con_connect = " ".join(cage.argv_bwrap(repo, casa=casa))
|
|
244
|
+
afirma("the Linux cage masks managed device keys with an empty filesystem",
|
|
245
|
+
f"--tmpfs {connect}" in con_connect, con_connect[-500:])
|
|
237
246
|
afirma("a sealed FILE reads as nothing instead",
|
|
238
247
|
f"--ro-bind-try /dev/null {real(os.path.join(casa, '.git-credentials'))}" in texto,
|
|
239
248
|
texto[-400:])
|