agents-city 0.3.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.es.md +294 -16
- package/README.md +291 -16
- 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 +5 -0
- package/bin/connect +5 -0
- package/bin/hall.html +208 -22
- package/bin/navegador.mjs +203 -13
- package/bin/serve.py +176 -111
- package/bin/test +18 -4
- 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-demo.py +67 -0
- package/bin/test-desinstala.py +170 -0
- package/bin/test-doctor.py +31 -0
- package/bin/test-i18n.py +189 -0
- package/bin/test-navegador.py +54 -0
- package/bin/test-seat.py +78 -20
- package/bin/test-security.py +2 -0
- package/bin/test-serve.py +414 -2
- package/bin/uninstall +5 -0
- package/city/web/dist/city.js +65 -65
- package/city/web/dist/index.html +18 -5
- package/city/web/dist-hall/hall.js +1566 -486
- package/city/web/index.html +17 -4
- package/city/web/src/bienvenida.ts +48 -341
- package/city/web/src/casa.ts +324 -0
- package/city/web/src/demo.ts +277 -0
- package/city/web/src/dialogo.ts +178 -0
- package/city/web/src/es.ts +281 -25
- package/city/web/src/explorador.ts +216 -0
- package/city/web/src/hall.ts +612 -139
- package/city/web/src/idioma.ts +21 -1
- package/city/web/src/main.ts +28 -5
- package/city/web/src/motores.ts +48 -1
- package/city/web/src/vista.ts +58 -0
- package/demo/graba.py +127 -0
- package/demo/grabaciones/legal.jsonl +22 -0
- package/demo/grabaciones/medicina.jsonl +22 -0
- package/demo/grabaciones/software.jsonl +22 -0
- 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 +122 -0
- package/plugin/scripts/desinstala.py +237 -0
- package/plugin/scripts/doctor.py +37 -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
|
|
package/bin/test-demo.py
CHANGED
|
@@ -86,6 +86,72 @@ def ordered(actual, expected):
|
|
|
86
86
|
return cursor == len(expected)
|
|
87
87
|
|
|
88
88
|
|
|
89
|
+
def grabaciones():
|
|
90
|
+
"""The recordings the Hall plays back must still be the story they claim.
|
|
91
|
+
|
|
92
|
+
They are made by running the real thing (`demo/graba.py`), which means they
|
|
93
|
+
can go stale silently: somebody edits a story, the terminal demo changes,
|
|
94
|
+
and the browser keeps playing last month's committee as if it were current.
|
|
95
|
+
This is the check that turns that into a failure instead of a lie.
|
|
96
|
+
"""
|
|
97
|
+
print(" the recordings the Hall plays back")
|
|
98
|
+
sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
|
|
99
|
+
sys.path.insert(0, os.path.join(RAIZ, "demo"))
|
|
100
|
+
import demos # noqa: PLC0415
|
|
101
|
+
from stories import STORIES # noqa: PLC0415
|
|
102
|
+
|
|
103
|
+
catalogo = demos.catalogo()
|
|
104
|
+
ids = [d["id"] for d in catalogo]
|
|
105
|
+
afirma("· every story has a recording", sorted(ids) == sorted(STORIES), str(ids))
|
|
106
|
+
for ficha in catalogo:
|
|
107
|
+
eventos = demos.eventos(ficha["id"])
|
|
108
|
+
historia = STORIES[ficha["id"]]
|
|
109
|
+
afirma(
|
|
110
|
+
f"· {ficha['id']}: the recording says what the story says",
|
|
111
|
+
ficha["titulo"] == historia["title"], f"{ficha['titulo']} != {historia['title']}",
|
|
112
|
+
)
|
|
113
|
+
afirma(
|
|
114
|
+
f"· {ficha['id']}: it opens with the story's own question",
|
|
115
|
+
eventos[0]["kind"] == "committee.opened"
|
|
116
|
+
and eventos[0]["summary"] == historia["turns"][0]["payload"]["question"],
|
|
117
|
+
str(eventos[0])[:200],
|
|
118
|
+
)
|
|
119
|
+
afirma(
|
|
120
|
+
f"· {ficha['id']}: and ends with the committee closed",
|
|
121
|
+
eventos[-1]["kind"] == "committee.closed", str(eventos[-1])[:200],
|
|
122
|
+
)
|
|
123
|
+
afirma(
|
|
124
|
+
f"· {ficha['id']}: every invited specialist speaks",
|
|
125
|
+
all(
|
|
126
|
+
quien in ficha["reparto"]
|
|
127
|
+
for quien in historia["turns"][0]["payload"]["participants"]
|
|
128
|
+
),
|
|
129
|
+
f"{ficha['reparto']} vs {historia['turns'][0]['payload']['participants']}",
|
|
130
|
+
)
|
|
131
|
+
afirma(
|
|
132
|
+
f"· {ficha['id']}: the turns are in order and numbered",
|
|
133
|
+
[e["seq"] for e in eventos] == list(range(1, len(eventos) + 1)),
|
|
134
|
+
str([e["seq"] for e in eventos]),
|
|
135
|
+
)
|
|
136
|
+
# A recording that carried ids, timestamps or a thread would replay a
|
|
137
|
+
# dead city's identifiers into somebody else's Hall.
|
|
138
|
+
afirma(
|
|
139
|
+
f"· {ficha['id']}: nothing volatile was recorded",
|
|
140
|
+
not any(
|
|
141
|
+
clave in e for e in eventos for clave in ("id", "at", "city", "thread", "sourceId")
|
|
142
|
+
),
|
|
143
|
+
str(eventos[0]),
|
|
144
|
+
)
|
|
145
|
+
afirma(
|
|
146
|
+
f"· {ficha['id']}: and no raw payload leaked into it",
|
|
147
|
+
all(set(e) <= {
|
|
148
|
+
"protocol", "seq", "kind", "actor", "role", "phase", "tone",
|
|
149
|
+
"title", "summary", "details", "target",
|
|
150
|
+
} for e in eventos),
|
|
151
|
+
str(sorted({k for e in eventos for k in e})),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
89
155
|
def main():
|
|
90
156
|
global FIXTURE
|
|
91
157
|
print("\n full Aurora demo on the real local bus")
|
|
@@ -301,6 +367,7 @@ def main():
|
|
|
301
367
|
stop_hubs(app)
|
|
302
368
|
shutil.rmtree(base, ignore_errors=True)
|
|
303
369
|
|
|
370
|
+
grabaciones()
|
|
304
371
|
return resumen("demo")
|
|
305
372
|
|
|
306
373
|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Getting the product off a machine, without taking anything else with it.
|
|
3
|
+
|
|
4
|
+
One assertion here matters more than the rest put together: an agent's home is
|
|
5
|
+
full of symlinks into real repositories and real folders of documents, and the
|
|
6
|
+
uninstaller walks straight through it. If it ever followed one of those links,
|
|
7
|
+
it would delete somebody's work. That is the check this file exists for.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
AQUI = os.path.dirname(os.path.abspath(__file__))
|
|
16
|
+
RAIZ = os.path.dirname(AQUI)
|
|
17
|
+
sys.path.insert(0, AQUI)
|
|
18
|
+
sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
|
|
19
|
+
|
|
20
|
+
import cities # noqa: E402
|
|
21
|
+
import desinstala # noqa: E402
|
|
22
|
+
from testlib import afirma, comprueba, resumen # noqa: E402
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def monta():
|
|
26
|
+
"""A machine with the product on it, and one repository beside it."""
|
|
27
|
+
casa = tempfile.mkdtemp()
|
|
28
|
+
trabajo = tempfile.mkdtemp()
|
|
29
|
+
|
|
30
|
+
repo = os.path.join(trabajo, "el-repo")
|
|
31
|
+
os.makedirs(os.path.join(repo, "src"))
|
|
32
|
+
with open(os.path.join(repo, "src", "importante.txt"), "w") as f:
|
|
33
|
+
# Deliberately big: the size check below has to be able to tell "walked
|
|
34
|
+
# into the repository" apart from "the product wrote a few more bytes
|
|
35
|
+
# than this fixture did", and a 17-byte file cannot carry that.
|
|
36
|
+
f.write("this must survive\n" + "x" * 400_000)
|
|
37
|
+
|
|
38
|
+
docs = os.path.join(trabajo, "manual")
|
|
39
|
+
os.makedirs(docs)
|
|
40
|
+
with open(os.path.join(docs, "uno.md"), "w") as f:
|
|
41
|
+
f.write("so must this")
|
|
42
|
+
|
|
43
|
+
ciudad = os.path.join(casa, "agents-city", "yo", "home")
|
|
44
|
+
agente = os.path.join(ciudad, "agents", "api")
|
|
45
|
+
os.makedirs(agente)
|
|
46
|
+
with open(os.path.join(ciudad, "city.yml"), "w") as f:
|
|
47
|
+
f.write("owner: yo\nname: home\n")
|
|
48
|
+
# The two links an agent's home is made of.
|
|
49
|
+
os.symlink(repo, os.path.join(agente, "el-repo"))
|
|
50
|
+
os.symlink(docs, os.path.join(agente, "manual"))
|
|
51
|
+
with open(os.path.join(agente, "CLAUDE.md"), "w") as f:
|
|
52
|
+
f.write("mine")
|
|
53
|
+
return casa, trabajo, repo, docs, ciudad
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def enlaces(casa, trabajo, repo, docs, ciudad):
|
|
57
|
+
print(" what it must never follow")
|
|
58
|
+
raiz = os.path.join(casa, "agents-city")
|
|
59
|
+
afirma("· the fixture really does link out of the city",
|
|
60
|
+
os.path.islink(os.path.join(ciudad, "agents", "api", "el-repo")), "")
|
|
61
|
+
|
|
62
|
+
tam = desinstala._tamano(raiz)
|
|
63
|
+
afirma("· measuring the install does not count the linked repo",
|
|
64
|
+
tam < 100_000, f"{tam} bytes — it walked into the repo")
|
|
65
|
+
|
|
66
|
+
quitado = desinstala._quita_carpeta(raiz)
|
|
67
|
+
afirma("· the install folder is gone", quitado and not os.path.exists(raiz), raiz)
|
|
68
|
+
afirma("· and the linked repository is untouched",
|
|
69
|
+
os.path.isfile(os.path.join(repo, "src", "importante.txt")), repo)
|
|
70
|
+
afirma("· and so is the linked folder of documents",
|
|
71
|
+
os.path.isfile(os.path.join(docs, "uno.md")), docs)
|
|
72
|
+
afirma("· the work folder still holds both",
|
|
73
|
+
sorted(os.listdir(trabajo)) == ["el-repo", "manual"], str(os.listdir(trabajo)))
|
|
74
|
+
|
|
75
|
+
print(" and what it does with what is not there")
|
|
76
|
+
comprueba("· removing a folder that never existed is not an error",
|
|
77
|
+
desinstala._quita_carpeta(os.path.join(casa, "no-existe")), False)
|
|
78
|
+
suelto = os.path.join(casa, "suelto")
|
|
79
|
+
os.symlink(repo, suelto)
|
|
80
|
+
afirma("· a link that IS the target is unlinked, not descended into",
|
|
81
|
+
desinstala._quita_carpeta(suelto) and not os.path.lexists(suelto)
|
|
82
|
+
and os.path.isdir(repo), repo)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def el_plan(casa):
|
|
86
|
+
print(" the plan")
|
|
87
|
+
pasos = desinstala.plan()
|
|
88
|
+
clases = [p["clase"] for p in pasos]
|
|
89
|
+
afirma("· it stops what is running before it deletes anything",
|
|
90
|
+
clases[0] == "procesos", str(clases))
|
|
91
|
+
afirma("· it asks Claude to forget the plugin", "plugin" in clases, str(clases))
|
|
92
|
+
carpetas = [p["ruta"] for p in pasos if p["clase"] == "carpeta"]
|
|
93
|
+
afirma("· it removes the cities root", any("agents-city" in r for r in carpetas), str(carpetas))
|
|
94
|
+
afirma("· it removes the cached disk index",
|
|
95
|
+
any(r.endswith(os.path.join("cache", "agents-city")) for r in carpetas), str(carpetas))
|
|
96
|
+
afirma("· it removes the bus folder", any("city-bus" in r for r in carpetas), str(carpetas))
|
|
97
|
+
afirma("· every folder in the plan actually exists",
|
|
98
|
+
all(os.path.exists(r) for r in carpetas), str(carpetas))
|
|
99
|
+
afirma("· nothing outside this product is in the plan",
|
|
100
|
+
all(casa in r or ".claude" in r or ".config" in r or ".cache" in r for r in carpetas),
|
|
101
|
+
str(carpetas))
|
|
102
|
+
|
|
103
|
+
print(" keeping your cities")
|
|
104
|
+
conservado = [p["ruta"] for p in desinstala.plan(conservar_ciudades=True)
|
|
105
|
+
if p["clase"] == "carpeta"]
|
|
106
|
+
afirma("· --keep-cities leaves the cities root alone",
|
|
107
|
+
not any(r == cities.raiz() for r in conservado), str(conservado))
|
|
108
|
+
afirma("· and still unwires the machine", len(conservado) >= 1, str(conservado))
|
|
109
|
+
|
|
110
|
+
print(" the npm package")
|
|
111
|
+
afirma("· it is not removed unless asked",
|
|
112
|
+
"npm" not in [p["clase"] for p in desinstala.plan()], "")
|
|
113
|
+
afirma("· and it is the very last thing when it is",
|
|
114
|
+
desinstala.plan(con_npm=True)[-1]["clase"] == "npm", "")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def la_puerta():
|
|
118
|
+
print(" the front door")
|
|
119
|
+
import io
|
|
120
|
+
from contextlib import redirect_stdout
|
|
121
|
+
|
|
122
|
+
salida = io.StringIO()
|
|
123
|
+
with redirect_stdout(salida):
|
|
124
|
+
codigo = desinstala.main([])
|
|
125
|
+
texto = salida.getvalue()
|
|
126
|
+
comprueba("· a bare uninstall exits cleanly", codigo, 0)
|
|
127
|
+
afirma("· and says plainly that it removed nothing",
|
|
128
|
+
"Nothing was removed" in texto, texto)
|
|
129
|
+
afirma("· and promises not to touch your repositories",
|
|
130
|
+
"NOT touch your repositories" in texto, texto)
|
|
131
|
+
afirma("· the cities root is still there afterwards",
|
|
132
|
+
os.path.isdir(cities.raiz()), cities.raiz())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main():
|
|
136
|
+
casa, trabajo, repo, docs, ciudad = monta()
|
|
137
|
+
viejo = dict(os.environ)
|
|
138
|
+
registro = cities.REGISTRO
|
|
139
|
+
try:
|
|
140
|
+
os.environ["AGENTS_CITY_HOME"] = os.path.join(casa, "agents-city")
|
|
141
|
+
os.environ["AGENTS_CITY_USER"] = "yo"
|
|
142
|
+
os.environ["XDG_CACHE_HOME"] = os.path.join(casa, "cache")
|
|
143
|
+
os.environ["CITY_DIR"] = os.path.join(casa, ".claude", "channels", "city-bus")
|
|
144
|
+
os.environ["CITY_DESKTOP"] = tempfile.mkdtemp()
|
|
145
|
+
cities.REGISTRO = os.path.join(casa, ".config", "agents-city", "cities")
|
|
146
|
+
for ruta in (os.environ["XDG_CACHE_HOME"] + "/agents-city",
|
|
147
|
+
os.environ["CITY_DIR"],
|
|
148
|
+
os.path.dirname(cities.REGISTRO)):
|
|
149
|
+
os.makedirs(ruta, exist_ok=True)
|
|
150
|
+
with open(os.path.join(ruta, "algo"), "w") as f:
|
|
151
|
+
f.write("x")
|
|
152
|
+
# city_env read CITY_DIR at import time, like the rest of the product.
|
|
153
|
+
import city_env
|
|
154
|
+
|
|
155
|
+
city_env.CANAL = os.environ["CITY_DIR"]
|
|
156
|
+
|
|
157
|
+
el_plan(casa)
|
|
158
|
+
la_puerta()
|
|
159
|
+
enlaces(casa, trabajo, repo, docs, ciudad)
|
|
160
|
+
finally:
|
|
161
|
+
cities.REGISTRO = registro
|
|
162
|
+
os.environ.clear()
|
|
163
|
+
os.environ.update(viejo)
|
|
164
|
+
shutil.rmtree(casa, ignore_errors=True)
|
|
165
|
+
shutil.rmtree(trabajo, ignore_errors=True)
|
|
166
|
+
return resumen("desinstala")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
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"))
|