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.
Files changed (96) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +240 -11
  3. package/README.md +238 -11
  4. package/benchmarks/latency/fake-claude-cli.mjs +2 -1
  5. package/benchmarks/reception/README.md +16 -0
  6. package/benchmarks/reception/run.mjs +84 -0
  7. package/bin/agents-city.js +1 -0
  8. package/bin/connect +5 -0
  9. package/bin/hall.html +117 -18
  10. package/bin/navegador.mjs +62 -1
  11. package/bin/serve.py +106 -97
  12. package/bin/test +14 -3
  13. package/bin/test-arnes.py +192 -0
  14. package/bin/test-cage.py +9 -0
  15. package/bin/test-channel.py +178 -12
  16. package/bin/test-claude-runtime.py +132 -4
  17. package/bin/test-connect-client.mjs +609 -0
  18. package/bin/test-connect.py +52 -0
  19. package/bin/test-contracts.py +214 -0
  20. package/bin/test-doctor.py +31 -0
  21. package/bin/test-i18n.py +189 -0
  22. package/bin/test-navegador.py +52 -0
  23. package/bin/test-seat.py +78 -20
  24. package/bin/test-security.py +2 -0
  25. package/bin/test-serve.py +292 -0
  26. package/city/web/dist-hall/hall.js +733 -209
  27. package/city/web/src/bienvenida.ts +14 -13
  28. package/city/web/src/casa.ts +98 -56
  29. package/city/web/src/demo.ts +61 -31
  30. package/city/web/src/es.ts +187 -27
  31. package/city/web/src/explorador.ts +70 -43
  32. package/city/web/src/hall.ts +468 -98
  33. package/city/web/src/idioma.ts +21 -1
  34. package/city/web/src/motores.ts +48 -1
  35. package/city/web/src/vista.ts +58 -0
  36. package/demo/graba.py +5 -10
  37. package/docs/managed-connect.md +237 -0
  38. package/docs/security.md +41 -2
  39. package/package.json +8 -4
  40. package/plugin/.claude-plugin/plugin.json +1 -1
  41. package/plugin/channel/adapter-prompts.ts +10 -0
  42. package/plugin/channel/adapter.js +78 -31
  43. package/plugin/channel/agents_city_hybrid_crypto_bg.wasm +0 -0
  44. package/plugin/channel/bus.js +109 -65
  45. package/plugin/channel/bus.ts +11 -1
  46. package/plugin/channel/client.js +67 -30
  47. package/plugin/channel/delivery-queue.ts +160 -21
  48. package/plugin/channel/hub/remote-roads.ts +32 -1
  49. package/plugin/channel/hub/road-controller.ts +116 -16
  50. package/plugin/channel/kinsh_vodozemac_wasm_bg.wasm +0 -0
  51. package/plugin/channel/licenses/CONNECT_CLIENT_THIRD_PARTY_NOTICES.md +21 -0
  52. package/plugin/channel/licenses/HYBRID_CRYPTO_THIRD_PARTY_NOTICES.md +12 -0
  53. package/plugin/channel/licenses/hybrid-crypto-Apache-2.0.txt +201 -0
  54. package/plugin/channel/licenses/keyring-MIT.txt +21 -0
  55. package/plugin/channel/licenses/vodozemac-Apache-2.0.txt +201 -0
  56. package/plugin/channel/local-hub.js +2080 -97
  57. package/plugin/channel/local-hub.ts +5 -4
  58. package/plugin/channel/managed-connect/bridge.ts +208 -0
  59. package/plugin/channel/managed-connect/cli.ts +416 -0
  60. package/plugin/channel/managed-connect/device.ts +15 -0
  61. package/plugin/channel/managed-connect/local-cities.ts +94 -0
  62. package/plugin/channel/managed-connect/person-message.ts +74 -0
  63. package/plugin/channel/managed-connect/reception-bridge.ts +341 -0
  64. package/plugin/channel/managed-connect/relay-session.ts +7 -0
  65. package/plugin/channel/managed-connect/storage.ts +653 -0
  66. package/plugin/channel/managed-connect/transport.ts +87 -0
  67. package/plugin/channel/managed-connect-cli.js +4777 -0
  68. package/plugin/channel/managed-connect-cli.ts +7 -0
  69. package/plugin/channel/managed-connect-client.d.ts +261 -0
  70. package/plugin/channel/managed-connect-client.js +6572 -0
  71. package/plugin/channel/managed-connect-client.manifest.json +38 -0
  72. package/plugin/channel/package-lock.json +123 -105
  73. package/plugin/channel/package.json +4 -4
  74. package/plugin/channel/protocol.ts +4 -0
  75. package/plugin/channel/reception.ts +896 -0
  76. package/plugin/channel/road-cli.ts +1 -1
  77. package/plugin/channel/runtime/arnes.json +189 -0
  78. package/plugin/channel/runtime/arnes.ts +47 -0
  79. package/plugin/channel/runtime/claude.ts +18 -0
  80. package/plugin/channel/runtime/codex-config.ts +51 -1
  81. package/plugin/channel/runtime/codex.ts +31 -11
  82. package/plugin/channel/runtime/kimi.ts +6 -4
  83. package/plugin/channel/runtime-files.ts +39 -5
  84. package/plugin/channel/runtime-gateway.js +426 -100
  85. package/plugin/channel/runtime-gateway.ts +12 -0
  86. package/plugin/channel/trust/agents-city-sandbox-roots.json +66 -0
  87. package/plugin/scripts/arnes.py +271 -0
  88. package/plugin/scripts/busca.py +46 -10
  89. package/plugin/scripts/cage.py +5 -0
  90. package/plugin/scripts/city-session.sh +144 -28
  91. package/plugin/scripts/crecimiento.py +4 -1
  92. package/plugin/scripts/demos.py +58 -43
  93. package/plugin/scripts/desinstala.py +36 -21
  94. package/plugin/scripts/doctor.py +37 -15
  95. package/plugin/scripts/read-card.py +48 -8
  96. 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
- marca = os.path.join(ruta, ".git")
528
- if os.path.isdir(marca):
529
- return "repo"
530
- if os.path.isfile(marca):
531
- return "worktree"
532
- return ""
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
- historia = next((d for d in catalogo if d["id"] == cual), None)
871
- if not historia:
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
- {"error": "that demo has no recording; run demo/graba.py"}, codigo=409
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[:2000],
939
- "recortada": len(entradas) > 2000,
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
- "model": ("model", re.compile(r"[a-z0-9][a-z0-9.-]{0,63}"), "that is not a model alias"),
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/capabilities.py",
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
- "city/web/src/repo-roles.ts",
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:])