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.
- 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 +170 -1
- package/bin/serve.py +201 -101
- package/bin/test +15 -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-diario.py +185 -0
- package/bin/test-doctor.py +31 -0
- package/bin/test-i18n.py +189 -0
- package/bin/test-navegador.py +61 -0
- package/bin/test-seat.py +78 -20
- package/bin/test-security.py +2 -0
- package/bin/test-serve.py +366 -0
- package/city/web/dist-hall/hall.js +789 -214
- package/city/web/src/bienvenida.ts +14 -13
- package/city/web/src/casa.ts +143 -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 +520 -103
- 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/diario.py +119 -0
- package/plugin/scripts/doctor.py +90 -15
- package/plugin/scripts/read-card.py +48 -8
- package/plugin/scripts/reception.py +665 -0
package/bin/test-contracts.py
CHANGED
|
@@ -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())
|
package/bin/test-doctor.py
CHANGED
|
@@ -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"))
|
package/bin/test-i18n.py
ADDED
|
@@ -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())
|