agents-city 0.4.0 → 0.5.2

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 (98) 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 +170 -1
  11. package/bin/serve.py +201 -101
  12. package/bin/test +15 -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-diario.py +185 -0
  21. package/bin/test-doctor.py +31 -0
  22. package/bin/test-i18n.py +189 -0
  23. package/bin/test-navegador.py +61 -0
  24. package/bin/test-seat.py +78 -20
  25. package/bin/test-security.py +2 -0
  26. package/bin/test-serve.py +366 -0
  27. package/city/web/dist-hall/hall.js +789 -214
  28. package/city/web/src/bienvenida.ts +14 -13
  29. package/city/web/src/casa.ts +143 -56
  30. package/city/web/src/demo.ts +61 -31
  31. package/city/web/src/es.ts +187 -27
  32. package/city/web/src/explorador.ts +70 -43
  33. package/city/web/src/hall.ts +520 -103
  34. package/city/web/src/idioma.ts +21 -1
  35. package/city/web/src/motores.ts +48 -1
  36. package/city/web/src/vista.ts +58 -0
  37. package/demo/graba.py +5 -10
  38. package/docs/managed-connect.md +237 -0
  39. package/docs/security.md +41 -2
  40. package/package.json +8 -4
  41. package/plugin/.claude-plugin/plugin.json +1 -1
  42. package/plugin/channel/adapter-prompts.ts +10 -0
  43. package/plugin/channel/adapter.js +78 -31
  44. package/plugin/channel/agents_city_hybrid_crypto_bg.wasm +0 -0
  45. package/plugin/channel/bus.js +109 -65
  46. package/plugin/channel/bus.ts +11 -1
  47. package/plugin/channel/client.js +67 -30
  48. package/plugin/channel/delivery-queue.ts +160 -21
  49. package/plugin/channel/hub/remote-roads.ts +32 -1
  50. package/plugin/channel/hub/road-controller.ts +116 -16
  51. package/plugin/channel/kinsh_vodozemac_wasm_bg.wasm +0 -0
  52. package/plugin/channel/licenses/CONNECT_CLIENT_THIRD_PARTY_NOTICES.md +21 -0
  53. package/plugin/channel/licenses/HYBRID_CRYPTO_THIRD_PARTY_NOTICES.md +12 -0
  54. package/plugin/channel/licenses/hybrid-crypto-Apache-2.0.txt +201 -0
  55. package/plugin/channel/licenses/keyring-MIT.txt +21 -0
  56. package/plugin/channel/licenses/vodozemac-Apache-2.0.txt +201 -0
  57. package/plugin/channel/local-hub.js +2080 -97
  58. package/plugin/channel/local-hub.ts +5 -4
  59. package/plugin/channel/managed-connect/bridge.ts +208 -0
  60. package/plugin/channel/managed-connect/cli.ts +416 -0
  61. package/plugin/channel/managed-connect/device.ts +15 -0
  62. package/plugin/channel/managed-connect/local-cities.ts +94 -0
  63. package/plugin/channel/managed-connect/person-message.ts +74 -0
  64. package/plugin/channel/managed-connect/reception-bridge.ts +341 -0
  65. package/plugin/channel/managed-connect/relay-session.ts +7 -0
  66. package/plugin/channel/managed-connect/storage.ts +653 -0
  67. package/plugin/channel/managed-connect/transport.ts +87 -0
  68. package/plugin/channel/managed-connect-cli.js +4777 -0
  69. package/plugin/channel/managed-connect-cli.ts +7 -0
  70. package/plugin/channel/managed-connect-client.d.ts +261 -0
  71. package/plugin/channel/managed-connect-client.js +6572 -0
  72. package/plugin/channel/managed-connect-client.manifest.json +38 -0
  73. package/plugin/channel/package-lock.json +123 -105
  74. package/plugin/channel/package.json +4 -4
  75. package/plugin/channel/protocol.ts +4 -0
  76. package/plugin/channel/reception.ts +896 -0
  77. package/plugin/channel/road-cli.ts +1 -1
  78. package/plugin/channel/runtime/arnes.json +189 -0
  79. package/plugin/channel/runtime/arnes.ts +47 -0
  80. package/plugin/channel/runtime/claude.ts +18 -0
  81. package/plugin/channel/runtime/codex-config.ts +51 -1
  82. package/plugin/channel/runtime/codex.ts +31 -11
  83. package/plugin/channel/runtime/kimi.ts +6 -4
  84. package/plugin/channel/runtime-files.ts +39 -5
  85. package/plugin/channel/runtime-gateway.js +426 -100
  86. package/plugin/channel/runtime-gateway.ts +12 -0
  87. package/plugin/channel/trust/agents-city-sandbox-roots.json +66 -0
  88. package/plugin/scripts/arnes.py +271 -0
  89. package/plugin/scripts/busca.py +46 -10
  90. package/plugin/scripts/cage.py +5 -0
  91. package/plugin/scripts/city-session.sh +144 -28
  92. package/plugin/scripts/crecimiento.py +4 -1
  93. package/plugin/scripts/demos.py +58 -43
  94. package/plugin/scripts/desinstala.py +36 -21
  95. package/plugin/scripts/diario.py +119 -0
  96. package/plugin/scripts/doctor.py +90 -15
  97. package/plugin/scripts/read-card.py +48 -8
  98. package/plugin/scripts/reception.py +665 -0
@@ -328,6 +328,140 @@ def varias_ciudades():
328
328
  shutil.rmtree(casa)
329
329
 
330
330
 
331
+ def motor_para_todos():
332
+ """The card says which model a window runs, once, whatever CLI runs it.
333
+
334
+ Both fields used to be Claude's alone. They are not: the native gateways
335
+ parse `--model` — and Codex `--effort` — out of the command string and send
336
+ them with the turn, which is why one key on the card can mean the same thing
337
+ for all four. This checks the launcher actually hands them over, because a
338
+ dropdown whose value never reaches a process is a lie with a nice font.
339
+ """
340
+ print(' the engine reaches every runtime')
341
+ fuente = open(os.path.join(RAIZ, 'plugin/scripts/city-session.sh'), encoding='utf-8').read()
342
+ afirma('· a native runtime command goes through con_motor, not raw',
343
+ 'con_motor "$win" "$KIND" "$OTRO"' in fuente, '')
344
+ afirma('· and so does the seat when it runs one',
345
+ 'con_motor seat "$SEAT_RUNTIME" "$SEAT_OTRO"' in fuente, '')
346
+
347
+ # The two functions, lifted out and exercised against a stub card reader.
348
+ casa = tempfile.mkdtemp()
349
+ # A stand-in for read-card.py, in the language the launcher invokes it in.
350
+ lector = os.path.join(casa, 'leer.py')
351
+ # It answers the batched form too, because that is what the launcher asks:
352
+ # a stub that only knows the one-field call would test a door nobody uses.
353
+ open(lector, 'w', encoding='utf-8').write(
354
+ 'import sys\n'
355
+ "CARD = {'model.dbt': 'gpt-5.6-sol', 'effort.dbt': 'max'}\n"
356
+ "campos = sys.argv[3:] if sys.argv[1:2] == ['--varios'] else sys.argv[-1:]\n"
357
+ "print('\\n'.join(CARD.get(c, '') for c in campos))\n"
358
+ )
359
+ trozo = fuente[fuente.index('motor_de() {'):fuente.index('runtime_de() {')]
360
+ guion = os.path.join(casa, 'motor.sh')
361
+ open(guion, 'w', encoding='utf-8').write(
362
+ f'LEER="{lector}"\nFICHA=/dev/null\n{trozo}\n'
363
+ 'printf "%s\\n" "$(con_motor "$1" "$2" "$3")"\n'
364
+ )
365
+
366
+ def con(ventana, motor, orden):
367
+ return subprocess.run(['bash', guion, ventana, motor, orden],
368
+ capture_output=True, text=True).stdout.strip()
369
+
370
+ comprueba('· claude gets both flags',
371
+ con('dbt', 'claude', 'claude'), 'claude --model gpt-5.6-sol --effort max')
372
+ comprueba('· codex gets both too — its gateway reads them off the command',
373
+ con('dbt', 'codex', 'codex'), 'codex --model gpt-5.6-sol --effort max')
374
+ comprueba('· opencode gets the model and no effort it cannot read',
375
+ con('dbt', 'opencode', 'opencode'), 'opencode --model gpt-5.6-sol')
376
+ comprueba('· and neither does kimi',
377
+ con('dbt', 'kimi', 'kimi'), 'kimi --model gpt-5.6-sol')
378
+ comprueba('· a window with nothing on the card gets no flags at all',
379
+ con('otra', 'codex', 'codex'), 'codex')
380
+ # A command that already says it wins: somebody who wrote the flag meant it.
381
+ comprueba('· an explicit --model on the card is never doubled',
382
+ con('dbt', 'codex', 'codex --model o3'), 'codex --model o3 --effort max')
383
+ comprueba('· nor an explicit --effort',
384
+ con('dbt', 'codex', 'codex --effort low'),
385
+ 'codex --effort low --model gpt-5.6-sol')
386
+ shutil.rmtree(casa, ignore_errors=True)
387
+
388
+
389
+ def asiento_con_su_arnes():
390
+ """The chair keeps the harness the person came with.
391
+
392
+ Claude behind the gateway is headless with a `city>` prompt in front of it.
393
+ That is right for a house, which must be able to RECEIVE work from the bus,
394
+ and wrong for the chair, where somebody works by hand with their own
395
+ plugins, statusline and slash commands. The city plugin's hooks already
396
+ report a normal session's prompts and answers onto the bus, so the chair
397
+ loses nothing by opening its own interface.
398
+
399
+ What must never differ between the two shapes are the two flags that make
400
+ the bus the only route between agents. A quieter product with a hole in it
401
+ is the failure this checks for.
402
+ """
403
+ print(' the chair keeps its own interface')
404
+ fuente = open(os.path.join(RAIZ, 'plugin/scripts/city-session.sh'), encoding='utf-8').read()
405
+ cuerpo = fuente[fuente.index('lanza_asiento_claude() {'):fuente.index('# The seat runs Claude')]
406
+ afirma('· both shapes of the chair come out of one function',
407
+ fuente.count('lanza_asiento_claude "') == 2, '')
408
+ afirma('· the tui branch runs the command itself',
409
+ 'lanza "$SESSION:seat" seat "$EQUIPO" "$entorno${CLAUDE_AUTH_PREFIX}$orden"' in cuerpo,
410
+ cuerpo)
411
+ afirma('· the gateway branch is still there, one card key away',
412
+ 'gateway_line seat "$EQUIPO" "$orden"' in cuerpo, cuerpo)
413
+ afirma('· and the flags are built once, before the branch, so they cannot differ',
414
+ cuerpo.count('$SETTINGS') == 0 and cuerpo.count('NO_PEER_TOOLS') == 0, cuerpo)
415
+ # Every Claude command in the file is built the same way: the engine
416
+ # through `con_motor` (which carries the "an explicit flag wins" guard) and
417
+ # the deal through `$CLAUDE_TRATO` (which is asked of the declaration). A
418
+ # branch that skips either is a window that silently runs differently.
419
+ ordenes = [l for l in fuente.split('\n')
420
+ if 'CLAUDE_SEAT="' in l or 'CLAUDE_REPO="' in l
421
+ or 'lanza_asiento_claude "' in l]
422
+ afirma('· every Claude launch in the file was found', len(ordenes) == 5, str(ordenes))
423
+ for linea in ordenes:
424
+ if linea.strip().startswith('lanza_asiento_claude "$CLAUDE_SEAT"'):
425
+ continue # this one passes a command already built above
426
+ afirma(f'· the engine goes through con_motor: {linea.strip()[:40]}…',
427
+ 'con_motor' in linea, linea)
428
+ afirma(f'· and the deal through the declaration: {linea.strip()[:40]}…',
429
+ '$CLAUDE_TRATO' in linea, linea)
430
+ afirma('· the chair still opens with its yolo flag',
431
+ any('$SEAT_YOLO_FLAG' in l for l in ordenes), str(ordenes))
432
+ afirma('· and the deal is asked of arnes.py, not respelled here',
433
+ 'arnes.py" flags claude' in fuente
434
+ and "crossSessionInbound" not in fuente
435
+ and "SendMessage,ListAgents" not in fuente, '')
436
+
437
+ # A house is not a chair: it must still be reachable from the bus.
438
+ afirma('· an agent house still goes through the gateway',
439
+ 'gateway_line "$win" "$path" "$CLAUDE_REPO"' in fuente, '')
440
+
441
+ # `ui.<window>`, lifted out and exercised.
442
+ casa = tempfile.mkdtemp()
443
+ lector = os.path.join(casa, 'leer.py')
444
+ open(lector, 'w', encoding='utf-8').write(
445
+ 'import sys\n'
446
+ "print({'ui.seat': 'gateway', 'ui.raro': 'nonsense'}.get(sys.argv[-1], ''))\n"
447
+ )
448
+ trozo = fuente[fuente.index('ui_de() {'):fuente.index('runtime_de() {')]
449
+ guion = os.path.join(casa, 'ui.sh')
450
+ open(guion, 'w', encoding='utf-8').write(
451
+ f'LEER="{lector}"\nFICHA=/dev/null\n{trozo}\nprintf "%s\\n" "$(ui_de "$1" "$2")"\n'
452
+ )
453
+
454
+ def ui(ventana, defecto):
455
+ return subprocess.run(['bash', guion, ventana, defecto],
456
+ capture_output=True, text=True).stdout.strip()
457
+
458
+ comprueba('· a card that says gateway gets the gateway', ui('seat', 'tui'), 'gateway')
459
+ comprueba('· a card that says nothing gets the default', ui('otra', 'tui'), 'tui')
460
+ comprueba('· and a house defaults the other way', ui('otra', 'gateway'), 'gateway')
461
+ comprueba('· a card that says nonsense is not obeyed', ui('raro', 'tui'), 'tui')
462
+ shutil.rmtree(casa, ignore_errors=True)
463
+
464
+
331
465
  def ventanas_gemelas():
332
466
  """Window, engine key and bus actor share one canonical repo slug."""
333
467
  print(' the window slug has one owner')
@@ -529,6 +663,83 @@ def ci_bootstrap():
529
663
  'the clean checkout would launch WebSocket doubles without ws installed')
530
664
 
531
665
 
666
+ def publicar_es_verificable():
667
+ """A release is a tag, and what it publishes can be checked by a stranger.
668
+
669
+ Three properties, and each one is a way this has already gone wrong or
670
+ could: the suite runs before the registry sees anything; the tag and the
671
+ manifests are made to agree, because a tag that lies publishes one version
672
+ under another's name forever; and the credential is an OIDC identity rather
673
+ than a stored token, because a secret that does not exist cannot leak.
674
+ """
675
+ print(' a release is a tag, and it is verifiable')
676
+ ruta = os.path.join(RAIZ, '.github', 'workflows', 'release.yml')
677
+ afirma('· there is a release workflow at all', os.path.isfile(ruta), ruta)
678
+ if not os.path.isfile(ruta):
679
+ return
680
+ release = open(ruta, encoding='utf-8').read()
681
+ prueba = open(os.path.join(RAIZ, '.github', 'workflows', 'test.yml'),
682
+ encoding='utf-8').read()
683
+
684
+ afirma('· it runs on a tag, not on a push to a branch',
685
+ "tags: ['v*']" in release and 'branches' not in release, release[:400])
686
+ # The last occurrence: the header comment names `npm publish` while
687
+ # explaining what this replaced, and a check that cannot tell a command
688
+ # from a sentence about it fails on its own documentation.
689
+ afirma('· the whole suite runs before anything is published',
690
+ 'uses: ./.github/workflows/test.yml' in release
691
+ and release.index('uses: ./.github/workflows/test.yml')
692
+ < release.rindex('run: npm publish'),
693
+ 'publish must depend on the suite, not race it')
694
+ afirma('· and the suite is the same one, called rather than copied',
695
+ 'workflow_call:' in prueba, 'test.yml must be reusable')
696
+ afirma('· needs: test — the dependency is declared, not implied',
697
+ 'needs: test' in release, release)
698
+ afirma('· the tag has to agree with package.json before it publishes',
699
+ 'GITHUB_REF_NAME#v' in release and 'exit 1' in release,
700
+ 'a tag that disagrees would publish one version under another name')
701
+ afirma('· it publishes with provenance, so the tarball names its commit',
702
+ '--provenance' in release, release)
703
+ afirma('· through a short-lived identity, with no token stored anywhere',
704
+ 'id-token: write' in release
705
+ and 'NPM_TOKEN' not in release and 'secrets.' not in release,
706
+ 'trusted publishing: a secret that does not exist cannot leak')
707
+ afirma('· and it asks for nothing else',
708
+ release.count('write') == 1, 'id-token is the only write permission')
709
+ # Node 22 bundles npm 10, which cannot exchange an OIDC token and so
710
+ # publishes UNAUTHENTICATED — the registry answers 404, which reads like a
711
+ # missing package and is really a missing credential. It cost one release
712
+ # to find out.
713
+ afirma('· with an npm new enough to exchange the token at all',
714
+ 'npm install -g npm@^11' in release and '11.5.1' in release,
715
+ 'trusted publishing needs npm >= 11.5.1; setup-node gives 10.x')
716
+ afirma('· and it refuses to publish with an older one rather than trying',
717
+ 'process.exit(1)' in release, release)
718
+ # prepublishOnly rebuilds and re-runs the whole suite. By hand that is the
719
+ # only safety net; here the suite has already run on three platforms and
720
+ # byte-compared the artifacts, so a fourth run verifies nothing and can
721
+ # only fail. It did, on a flaky test, after everything real had passed.
722
+ afirma('· and it does not run the suite a fourth time to publish',
723
+ '--ignore-scripts' in release,
724
+ 'prepublishOnly duplicates what needs: test already proved')
725
+ # Skipping the scripts also skips the BUILD, and `city/web/dist*` are
726
+ # gitignored — they exist only because something builds them. Turning the
727
+ # test half off by turning the whole script off shipped a package with no
728
+ # map and no Hall bundle: 447 files instead of 503.
729
+ afirma('· but it still builds the gitignored half of the package',
730
+ 'run: npm run build' in release
731
+ and release.index('run: npm run build') < release.rindex('run: npm publish'),
732
+ 'a publish with no build ships no front end')
733
+ afirma('· and checks the front end is in the tarball before sending it',
734
+ 'city/web/dist/index.html' in release and 'city/web/dist-hall/hall.js' in release,
735
+ 'the gitignored artifacts are the ones nobody notices missing')
736
+ paquete = json.load(open(os.path.join(RAIZ, 'package.json'), encoding='utf-8'))
737
+ afirma('· and the build has one spelling, shared with a publish by hand',
738
+ paquete['scripts'].get('prepublishOnly', '').startswith('npm run build')
739
+ and 'city/web' in paquete['scripts'].get('build', ''),
740
+ str(paquete.get('scripts')))
741
+
742
+
532
743
  def main():
533
744
  print()
534
745
  resolvedores()
@@ -539,11 +750,14 @@ def main():
539
750
  demo_coherente()
540
751
  varias_ciudades()
541
752
  ventanas_gemelas()
753
+ motor_para_todos()
754
+ asiento_con_su_arnes()
542
755
  canal_compilado()
543
756
  plugin_canal()
544
757
  puerta_npm()
545
758
  documentacion_publica()
546
759
  ci_bootstrap()
760
+ publicar_es_verificable()
547
761
  return resumen('contracts')
548
762
 
549
763
 
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env python3
2
+ """The journal: what it records, and everything it must survive.
3
+
4
+ This exists so a failure on somebody else's machine is a file they can send
5
+ rather than a story they have to remember. That promise has two halves, and the
6
+ second is the one with teeth:
7
+
8
+ · it records what happened — and
9
+ · it is safe to attach without reading it first, it never grows without
10
+ bound, and it can never be the reason a request failed.
11
+
12
+ A log that breaks the thing it is logging is worse than no log at all, so most
13
+ of what follows is the unhappy path: an unwritable directory, a value that
14
+ cannot be serialised, a line somebody corrupted, a structure with no bottom.
15
+ """
16
+
17
+ import os
18
+ import shutil
19
+ import sys
20
+ import tempfile
21
+
22
+ AQUI = os.path.dirname(os.path.abspath(__file__))
23
+ RAIZ = os.path.dirname(AQUI)
24
+ sys.path.insert(0, AQUI)
25
+ sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
26
+
27
+ import cities # noqa: E402
28
+ import diario # noqa: E402
29
+ from testlib import afirma, comprueba, resumen # noqa: E402
30
+
31
+
32
+ def ciudad():
33
+ """A city with an identity, which is what the journal's path is keyed on."""
34
+ base = tempfile.mkdtemp()
35
+ datos = os.path.join(base, "home")
36
+ os.makedirs(datos)
37
+ with open(os.path.join(datos, "city.yml"), "w", encoding="utf-8") as f:
38
+ f.write("owner: quien\nname: home\nslug: home\nid: city-diario-prueba\n")
39
+ return base, datos
40
+
41
+
42
+ def lo_que_registra(datos):
43
+ print(" what it records")
44
+ diario.apunta(datos, "post", ruta="/api/agentes", estado=400, error="an agent needs a name")
45
+ diario.apunta(datos, "browser", que="api refused", donde="/api/agentes")
46
+ lineas = diario.lee(datos)
47
+ comprueba("· one line per thing that happened", len(lineas), 2)
48
+ comprueba("· in the order they happened", lineas[0]["tipo"], "post")
49
+ afirma("· with the moment", bool(lineas[0].get("t")), str(lineas[0]))
50
+ comprueba("· and the reason a request was refused",
51
+ lineas[0]["error"], "an agent needs a name")
52
+ afirma("· the browser's half lands in the same file as the server's",
53
+ lineas[1]["tipo"] == "browser" and lineas[1]["que"] == "api refused", str(lineas[1]))
54
+ afirma("· the file is where the runtime keeps this city's things",
55
+ diario.ruta(datos).endswith("hall.jsonl")
56
+ and cities.identidad(datos).lower().replace("_", "-")[:20] in diario.ruta(datos).lower(),
57
+ diario.ruta(datos))
58
+
59
+
60
+ def nunca_un_secreto(datos):
61
+ print(" what it must never record")
62
+ diario.apunta(
63
+ datos, "post",
64
+ PASE="s3cr3t-pase-value", token="tok_abc", Authorization="Bearer xyz",
65
+ api_key="k", password="p",
66
+ anidado={"cookie": "c", "inocente": "visible"},
67
+ suelto="sk-ant-" + "A" * 44,
68
+ )
69
+ linea = diario.lee(datos)[-1]
70
+ for clave in ("PASE", "token", "Authorization", "api_key", "password"):
71
+ comprueba(f"· {clave} by name", linea.get(clave), "[redacted]")
72
+ comprueba("· and inside a nested object", linea["anidado"]["cookie"], "[redacted]")
73
+ comprueba("· while its neighbour survives", linea["anidado"]["inocente"], "visible")
74
+ afirma("· a value merely SHAPED like a credential goes too",
75
+ "[redacted]" in linea["suelto"] and "AAAA" not in linea["suelto"], linea["suelto"])
76
+
77
+ # The regression that made this worse than useless: a temp directory is a
78
+ # long run of characters, and redacting it turned a real error message into
79
+ # `[redacted]` while protecting nothing.
80
+ for ruta in ("/var/folders/xy/T/tmpab12cd34ef56gh78ij90klmnopqrs/city",
81
+ "~/.agents-city/quien/home",
82
+ "/Users/alguien/codigo/un-repo-con-nombre-larguisimo-de-verdad"):
83
+ comprueba(f"· but a path is not a credential: {ruta[:24]}…", diario.limpia(ruta), ruta)
84
+
85
+
86
+ def nunca_rompe_la_peticion(datos):
87
+ print(" what it must survive")
88
+ # It is called from inside request handlers. Anything it raises becomes a
89
+ # 500 on a request that had already succeeded.
90
+ imposible = os.path.join(datos, "no-existe", "ni-va-a-existir")
91
+ open(os.path.join(datos, "fichero"), "w").close()
92
+ for roto, como in (
93
+ (os.path.join(datos, "fichero", "sub"), "a file where a directory should be"),
94
+ (imposible, "a directory nobody created"),
95
+ ):
96
+ try:
97
+ diario.apunta(roto, "post", ruta="/x")
98
+ afirma(f"· {como} is survived, not raised", True, "")
99
+ except Exception as e: # noqa: BLE001 that is the assertion
100
+ afirma(f"· {como} is survived, not raised", False, f"{type(e).__name__}: {e}")
101
+
102
+ class NoSerializa:
103
+ pass
104
+
105
+ try:
106
+ diario.apunta(datos, "post", objeto=NoSerializa())
107
+ afirma("· a value that cannot be written is survived too", True, "")
108
+ except Exception as e: # noqa: BLE001
109
+ afirma("· a value that cannot be written is survived too", False, str(e))
110
+
111
+ print(" and what it must not do to itself")
112
+ hondo = {"a": {}}
113
+ nodo = hondo["a"]
114
+ for _ in range(40):
115
+ nodo["a"] = {}
116
+ nodo = nodo["a"]
117
+ diario.apunta(datos, "post", hondo=hondo)
118
+ afirma("· a structure with no bottom does not recurse forever",
119
+ len(diario.lee(datos)) > 0, "")
120
+ diario.apunta(datos, "post", larga="x" * 5000, lista=list(range(500)))
121
+ linea = diario.lee(datos)[-1]
122
+ afirma("· a very long string is cut, and says so",
123
+ len(linea["larga"]) <= 501 and linea["larga"].endswith("…"), str(len(linea["larga"])))
124
+ afirma("· and a very long list is cut", len(linea["lista"]) <= 40, str(len(linea["lista"])))
125
+
126
+
127
+ def se_puede_leer_siempre(datos):
128
+ print(" and reading it")
129
+ with open(diario.ruta(datos), "a", encoding="utf-8") as f:
130
+ f.write("esto no es json\n\n")
131
+ lineas = diario.lee(datos)
132
+ afirma("· a corrupted line does not stop the rest being read",
133
+ any(l.get("tipo") == "unreadable" for l in lineas) and len(lineas) > 1, str(lineas[-2:]))
134
+ comprueba("· a city with no journal reads as nothing, not as an error",
135
+ diario.lee(tempfile.mkdtemp()), [])
136
+
137
+
138
+ def no_crece_sin_fin(datos):
139
+ print(" and it does not grow without bound")
140
+ limite = diario.LIMITE
141
+ diario.LIMITE = 2000
142
+ try:
143
+ for i in range(60):
144
+ diario.apunta(datos, "post", ruta="/api/x", relleno="y" * 200, i=i)
145
+ actual = os.path.getsize(diario.ruta(datos))
146
+ afirma("· it rotates instead of growing", actual < 2000 * 3, str(actual))
147
+ afirma("· and keeps the previous one, so the rotation is not a hole",
148
+ os.path.isfile(diario.ruta(datos) + ".1"), "")
149
+ lineas = diario.lee(datos, 500)
150
+ afirma("· reading spans the rotation, oldest first",
151
+ len(lineas) > 20
152
+ and [l.get("i") for l in lineas if "i" in l]
153
+ == sorted(l.get("i") for l in lineas if "i" in l),
154
+ str([l.get("i") for l in lineas][:8]))
155
+ finally:
156
+ diario.LIMITE = limite
157
+
158
+
159
+ def main():
160
+ # An app home of its own. Without this the journal's path is derived from
161
+ # `cities.raiz()`, which is the caller's real `~/.agents-city` — so this
162
+ # suite wrote into the machine it was running on, and read back somebody
163
+ # else's lines. A test that touches the real home is worse than a flaky
164
+ # one: it is a test that changes the thing it is measuring.
165
+ previo = os.environ.get("AGENTS_CITY_HOME")
166
+ os.environ["AGENTS_CITY_HOME"] = tempfile.mkdtemp()
167
+ base, datos = ciudad()
168
+ try:
169
+ lo_que_registra(datos)
170
+ nunca_un_secreto(datos)
171
+ nunca_rompe_la_peticion(datos)
172
+ se_puede_leer_siempre(datos)
173
+ no_crece_sin_fin(datos)
174
+ finally:
175
+ shutil.rmtree(os.environ["AGENTS_CITY_HOME"], ignore_errors=True)
176
+ if previo is None:
177
+ os.environ.pop("AGENTS_CITY_HOME", None)
178
+ else:
179
+ os.environ["AGENTS_CITY_HOME"] = previo
180
+ shutil.rmtree(base, ignore_errors=True)
181
+ return resumen("diario")
182
+
183
+
184
+ if __name__ == "__main__":
185
+ sys.exit(main())
@@ -135,5 +135,36 @@ def revision_del_entorno():
135
135
 
136
136
 
137
137
 
138
+ def puerta_de_config():
139
+ """`doctor --config` is a door on the environment report, not a new binary.
140
+
141
+ It is the command that turns "we respect your CLI configuration" from a
142
+ sentence in a README into something a person can check on their own machine
143
+ before trusting this with it.
144
+ """
145
+ print(' the config report has a door')
146
+ import io
147
+ from contextlib import redirect_stdout
148
+
149
+ guion = os.path.join(RAIZ, 'plugin', 'scripts', 'doctor.py')
150
+ afirma('· doctor --help mentions it',
151
+ '--config' in open(guion, encoding='utf-8').read(), '')
152
+ for bandera in ('--config', 'config'):
153
+ salida = io.StringIO()
154
+ with redirect_stdout(salida):
155
+ codigo = doctor.main(['doctor', bandera])
156
+ texto = salida.getvalue()
157
+ comprueba(f'· `doctor {bandera}` exits cleanly', codigo, 0)
158
+ afirma(f'· `doctor {bandera}` prints the deal, not the environment',
159
+ 'the deal' in texto and 'we inherit' in texto, texto[:300])
160
+ salida = io.StringIO()
161
+ with redirect_stdout(salida):
162
+ doctor.main(['doctor', '--config', '--json'])
163
+ datos = json.loads(salida.getvalue())
164
+ afirma('· and --json answers with data, one entry per runtime',
165
+ isinstance(datos, list) and len(datos) == 4, str(datos)[:200])
166
+
167
+
138
168
  revision_del_entorno()
169
+ puerta_de_config()
139
170
  sys.exit(resumen("doctor"))
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env python3
2
+ """Translation coverage, as a number instead of a habit.
3
+
4
+ The Hall speaks Spanish and English. It got there by wrapping strings in `t()`
5
+ one call site at a time, which means coverage was decided per line by whoever
6
+ happened to be looking — and it showed: whole views shipped in English inside an
7
+ otherwise Spanish page, and nothing failed.
8
+
9
+ The obvious mechanism, sweeping the rendered DOM and translating what matches a
10
+ dictionary key, is the wrong one. At DOM time there is no way to tell a sentence
11
+ this product wrote from a city or agent name somebody typed, so a person whose
12
+ city is called `Overview` would watch it rename itself. The distinction has to
13
+ be made where it still exists: in the source, between a literal and an
14
+ interpolation.
15
+
16
+ So this is the enforcement half, and it is the half that actually fixes the
17
+ habit. It reads the render paths, pulls out every English sentence a person will
18
+ see, and fails when one has no Spanish. Nothing changes at runtime, so no user's
19
+ data can be mistranslated — and a new view cannot quietly ship untranslated.
20
+ """
21
+
22
+ import os
23
+ import re
24
+ import sys
25
+
26
+ AQUI = os.path.dirname(os.path.abspath(__file__))
27
+ RAIZ = os.path.dirname(AQUI)
28
+ FUENTE = os.path.join(RAIZ, "city", "web", "src")
29
+ sys.path.insert(0, AQUI)
30
+ from testlib import afirma, resumen # noqa: E402
31
+
32
+ #: The files that render something a person reads.
33
+ VISTAS = ("hall.ts", "bienvenida.ts", "casa.ts", "demo.ts", "explorador.ts",
34
+ "dialogo.ts", "motores.ts")
35
+
36
+ #: A filename, an identifier, a single word of markup. Matched whole, so it
37
+ #: cannot hide a real sentence.
38
+ NO_ES_PROSA = re.compile(r"^(?:[\w.\-/]+|.{0,3})$")
39
+
40
+
41
+ def normaliza(s):
42
+ return re.sub(r"\s+", " ", s.replace("’", "'")).strip()
43
+
44
+
45
+ def sin_lo_traducido(texto):
46
+ """The source with every `_( … )` and `plural( … )` call cut out.
47
+
48
+ What is left is, by construction, the text nobody wrapped. Cutting first
49
+ also removes the `<b>` and `<code>` fragments *inside* a translated
50
+ paragraph, which are not sentences of their own and must not be counted as
51
+ missing ones.
52
+ """
53
+ fuera = []
54
+ i = 0
55
+ while i < len(texto):
56
+ for aguja in ("_(", "plural("):
57
+ j = i if texto.startswith(aguja, i) else -1
58
+ # `_(` must not be the tail of a longer identifier.
59
+ if j == 0 or (j > 0 and not (texto[j - 1].isalnum() or texto[j - 1] == "_")):
60
+ if j >= 0:
61
+ break
62
+ else:
63
+ fuera.append(texto[i])
64
+ i += 1
65
+ continue
66
+ hondo, k = 0, i + len(aguja) - 1
67
+ while k < len(texto):
68
+ if texto[k] == "(":
69
+ hondo += 1
70
+ elif texto[k] == ")":
71
+ hondo -= 1
72
+ if hondo == 0:
73
+ break
74
+ k += 1
75
+ i = k + 1
76
+ return "".join(fuera)
77
+
78
+
79
+ def visibles(texto):
80
+ """Every English sentence this file puts on screen unwrapped.
81
+
82
+ Only inside template literals: that is where this codebase builds markup,
83
+ and it keeps TypeScript's own `Promise<void>` from reading as a tag. Two
84
+ shapes, because those are the two a template can produce — text between
85
+ tags, and the attributes a person actually reads.
86
+
87
+ Anything holding a `${` is skipped. That is an interpolation, and an
88
+ interpolation is precisely the thing that must never be translated blind:
89
+ it is where a city's name and an agent's name come from.
90
+ """
91
+ crudo = sin_lo_traducido(texto)
92
+ fuera = set()
93
+ for plantilla in re.findall(r"`(?:[^`\\]|\\.)*`", crudo, re.S):
94
+ for bruto in re.findall(r">([^<>{}\n]+)<", plantilla):
95
+ limpio = normaliza(bruto)
96
+ if limpio and "$" not in limpio and not NO_ES_PROSA.match(limpio):
97
+ fuera.add(limpio)
98
+ for bruto in re.findall(
99
+ r'(?:title|placeholder|aria-label)="([^"${}\n]+)"', plantilla
100
+ ):
101
+ limpio = normaliza(bruto)
102
+ if limpio and not NO_ES_PROSA.match(limpio):
103
+ fuera.add(limpio)
104
+ return fuera
105
+
106
+
107
+ def traducidas():
108
+ """Every key the Spanish dictionary answers to, normalised the way
109
+ `idioma.ts` normalises it before looking one up."""
110
+ texto = open(os.path.join(FUENTE, "es.ts"), encoding="utf-8").read()
111
+ claves = set()
112
+ for a, b, c in re.findall(
113
+ r"^\s*(?:'((?:[^'\\]|\\.)*)'|\"((?:[^\"\\]|\\.)*)\"|([A-Za-z][A-Za-z0-9_]*)):",
114
+ texto,
115
+ re.M,
116
+ ):
117
+ clave = (a or b or c).replace("\\'", "'").replace('\\"', '"')
118
+ if clave:
119
+ claves.add(normaliza(clave))
120
+ return claves
121
+
122
+
123
+ def pedidas(texto):
124
+ """Every key the code asks `t()` for. A sentence can be wrapped and still
125
+ untranslated — that is a miss the extractor above cannot see, because the
126
+ wrapping is exactly what hides it from a `>text<` scan."""
127
+ fuera = set()
128
+ for patron in (r"_\(\s*'((?:[^'\\]|\\.)*)'", r'_\(\s*"((?:[^"\\]|\\.)*)"',
129
+ r"_\(\s*`((?:[^`\\]|\\.)*)`"):
130
+ for bruto in re.findall(patron, texto, re.S):
131
+ clave = normaliza(bruto.replace("\\'", "'").replace('\\"', '"'))
132
+ # A key built from a variable is resolved at runtime; there is
133
+ # nothing here to check it against.
134
+ if clave and "${" not in clave:
135
+ fuera.add(clave)
136
+ return fuera
137
+
138
+
139
+ def cobertura():
140
+ print(" every sentence on screen has a Spanish one")
141
+ claves = traducidas()
142
+ sin_traducir = {}
143
+ for nombre in VISTAS:
144
+ texto = open(os.path.join(FUENTE, nombre), encoding="utf-8").read()
145
+ for frase in visibles(texto) | pedidas(texto):
146
+ if frase not in claves:
147
+ sin_traducir.setdefault(nombre, []).append(frase)
148
+ for nombre in VISTAS:
149
+ faltan = sin_traducir.get(nombre, [])
150
+ afirma(
151
+ f"· {nombre}",
152
+ not faltan,
153
+ "untranslated: " + " | ".join(sorted(faltan)[:6]) + (" …" if len(faltan) > 6 else ""),
154
+ )
155
+
156
+
157
+ def el_mecanismo():
158
+ print(" and the mechanism that keeps it honest")
159
+ idioma = open(os.path.join(FUENTE, "idioma.ts"), encoding="utf-8").read()
160
+ afirma(
161
+ "· the dictionary is keyed by the English sentence, so a miss degrades to English",
162
+ "ES[clave(fuente)] ?? fuente" in idioma,
163
+ idioma,
164
+ )
165
+ afirma(
166
+ "· whitespace and typographic apostrophes are normalised on both sides",
167
+ "\\u2018" in idioma and "\\s+" in idioma,
168
+ idioma,
169
+ )
170
+ # The one thing a DOM sweep could not do, said as a check: nothing
171
+ # translates a value that came from outside this codebase.
172
+ for nombre in VISTAS:
173
+ texto = open(os.path.join(FUENTE, nombre), encoding="utf-8").read()
174
+ malos = re.findall(r"_\(\s*(?:esc\(|E\.|this\.p\.esc\()", texto)
175
+ afirma(
176
+ f"· {nombre} never translates an escaped or server-sent value",
177
+ not malos,
178
+ str(malos[:3]),
179
+ )
180
+
181
+
182
+ def main():
183
+ cobertura()
184
+ el_mecanismo()
185
+ return resumen("i18n")
186
+
187
+
188
+ if __name__ == "__main__":
189
+ sys.exit(main())