agents-city 0.3.0-beta.21
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 +16 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.es.md +2021 -0
- package/README.md +1999 -0
- package/benchmarks/committee/README.md +39 -0
- package/benchmarks/committee/metrics.py +51 -0
- package/benchmarks/committee/run.py +64 -0
- package/benchmarks/committee/traces/01-single.json +8 -0
- package/benchmarks/committee/traces/02-mesh.json +16 -0
- package/benchmarks/committee/traces/03-chair.json +15 -0
- package/benchmarks/latency/README.md +35 -0
- package/benchmarks/latency/fake-claude-cli.mjs +119 -0
- package/benchmarks/latency/fake-native-server.mjs +340 -0
- package/benchmarks/latency/fake-tui.py +91 -0
- package/benchmarks/latency/live.py +380 -0
- package/benchmarks/stress/README.md +20 -0
- package/benchmarks/stress/run.py +487 -0
- package/bin/agents +4 -0
- package/bin/agents-city.js +116 -0
- package/bin/benchmark +17 -0
- package/bin/bus +6 -0
- package/bin/cities +3 -0
- package/bin/city +86 -0
- package/bin/committee +6 -0
- package/bin/demo +200 -0
- package/bin/exit +5 -0
- package/bin/hall +73 -0
- package/bin/hall.html +426 -0
- package/bin/logs +3 -0
- package/bin/report.py +14 -0
- package/bin/reset +3 -0
- package/bin/road +3 -0
- package/bin/seat +10 -0
- package/bin/serve.py +1428 -0
- package/bin/setup.py +753 -0
- package/bin/skills +3 -0
- package/bin/test +131 -0
- package/bin/test-adapter.py +183 -0
- package/bin/test-admision.py +99 -0
- package/bin/test-avatar.py +58 -0
- package/bin/test-benchmark.py +40 -0
- package/bin/test-broker.py +334 -0
- package/bin/test-cage.py +206 -0
- package/bin/test-card.py +402 -0
- package/bin/test-channel.py +379 -0
- package/bin/test-cities.py +453 -0
- package/bin/test-claude-runtime.py +252 -0
- package/bin/test-committee.py +340 -0
- package/bin/test-contracts.py +544 -0
- package/bin/test-crecimiento.py +164 -0
- package/bin/test-demo.py +308 -0
- package/bin/test-doctor.py +106 -0
- package/bin/test-domains.py +151 -0
- package/bin/test-evidencia.py +50 -0
- package/bin/test-exit.py +128 -0
- package/bin/test-hall-protocol.py +93 -0
- package/bin/test-launch.py +169 -0
- package/bin/test-live-feed.py +508 -0
- package/bin/test-pairing.py +99 -0
- package/bin/test-parcels.py +283 -0
- package/bin/test-runtime-failures.py +252 -0
- package/bin/test-runtime-ui.py +488 -0
- package/bin/test-runtime.py +268 -0
- package/bin/test-rutas.py +80 -0
- package/bin/test-seat.py +1386 -0
- package/bin/test-security.py +150 -0
- package/bin/test-serve.py +1057 -0
- package/bin/test-stress.py +64 -0
- package/bin/test-widgets.py +143 -0
- package/bin/test-workspace.py +188 -0
- package/bin/testlib.py +95 -0
- package/bin/tokens.py +14 -0
- package/bus/scripts/deploy.sh +60 -0
- package/bus/scripts/mint-token.sh +68 -0
- package/bus/scripts/setup-dev.sh +79 -0
- package/bus/scripts/test-channel.ts +8 -0
- package/bus/scripts/test-hub.ts +266 -0
- package/bus/scripts/test-local.sh +40 -0
- package/bus/scripts/test-queue.ts +132 -0
- package/bus/worker/package-lock.json +1971 -0
- package/bus/worker/package.json +17 -0
- package/bus/worker/src/index.ts +485 -0
- package/bus/worker/tsconfig.json +13 -0
- package/bus/worker/wrangler.toml +32 -0
- package/city/oven/README.md +59 -0
- package/city/oven/collect.py +43 -0
- package/city/oven/oven.html +4 -0
- package/city/scripts/history.py +120 -0
- package/city/scripts/seed.py +442 -0
- package/city/web/assets/modelos/License.txt +28 -0
- package/city/web/assets/modelos/PROCEDENCIA.txt +3 -0
- package/city/web/assets/modelos/building-a.glb +0 -0
- package/city/web/assets/modelos/building-b.glb +0 -0
- package/city/web/assets/modelos/building-c.glb +0 -0
- package/city/web/assets/modelos/building-d.glb +0 -0
- package/city/web/assets/modelos/building-e.glb +0 -0
- package/city/web/assets/modelos/building-f.glb +0 -0
- package/city/web/assets/modelos/building-g.glb +0 -0
- package/city/web/assets/modelos/building-h.glb +0 -0
- package/city/web/assets/sprites/building-a.png +0 -0
- package/city/web/assets/sprites/building-b.png +0 -0
- package/city/web/assets/sprites/building-c.png +0 -0
- package/city/web/assets/sprites/building-d.png +0 -0
- package/city/web/assets/sprites/building-e.png +0 -0
- package/city/web/assets/sprites/building-f.png +0 -0
- package/city/web/assets/sprites/building-g.png +0 -0
- package/city/web/assets/sprites/building-h.png +0 -0
- package/city/web/assets/sprites/building-i.png +0 -0
- package/city/web/assets/sprites/building-j.png +0 -0
- package/city/web/assets/sprites/building-k.png +0 -0
- package/city/web/assets/sprites/building-l.png +0 -0
- package/city/web/assets/sprites/building-m.png +0 -0
- package/city/web/assets/sprites/building-n.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-a.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-b.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-c.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-d.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-e.png +0 -0
- package/city/web/assets/sprites/catalogo.json +47 -0
- package/city/web/assets/sprites/detail-awning-wide.png +0 -0
- package/city/web/assets/sprites/detail-awning.png +0 -0
- package/city/web/assets/sprites/detail-overhang-wide.png +0 -0
- package/city/web/assets/sprites/detail-overhang.png +0 -0
- package/city/web/assets/sprites/detail-parasol-a.png +0 -0
- package/city/web/assets/sprites/detail-parasol-b.png +0 -0
- package/city/web/assets/sprites/low-detail-building-a.png +0 -0
- package/city/web/assets/sprites/low-detail-building-b.png +0 -0
- package/city/web/assets/sprites/low-detail-building-c.png +0 -0
- package/city/web/assets/sprites/low-detail-building-d.png +0 -0
- package/city/web/assets/sprites/low-detail-building-e.png +0 -0
- package/city/web/assets/sprites/low-detail-building-f.png +0 -0
- package/city/web/assets/sprites/low-detail-building-g.png +0 -0
- package/city/web/assets/sprites/low-detail-building-h.png +0 -0
- package/city/web/assets/sprites/low-detail-building-i.png +0 -0
- package/city/web/assets/sprites/low-detail-building-j.png +0 -0
- package/city/web/assets/sprites/low-detail-building-k.png +0 -0
- package/city/web/assets/sprites/low-detail-building-l.png +0 -0
- package/city/web/assets/sprites/low-detail-building-m.png +0 -0
- package/city/web/assets/sprites/low-detail-building-n.png +0 -0
- package/city/web/assets/sprites/low-detail-building-wide-a.png +0 -0
- package/city/web/assets/sprites/low-detail-building-wide-b.png +0 -0
- package/city/web/assets/sprites/medidas.json +248 -0
- package/city/web/dist/city.js +939 -0
- package/city/web/dist/index.html +294 -0
- package/city/web/dist/modelos/License.txt +28 -0
- package/city/web/dist/modelos/PROCEDENCIA.txt +3 -0
- package/city/web/dist/modelos/building-a.glb +0 -0
- package/city/web/dist/modelos/building-b.glb +0 -0
- package/city/web/dist/modelos/building-c.glb +0 -0
- package/city/web/dist/modelos/building-d.glb +0 -0
- package/city/web/dist/modelos/building-e.glb +0 -0
- package/city/web/dist/modelos/building-f.glb +0 -0
- package/city/web/dist/modelos/building-g.glb +0 -0
- package/city/web/dist/modelos/building-h.glb +0 -0
- package/city/web/dist/sprites/building-a.png +0 -0
- package/city/web/dist/sprites/building-b.png +0 -0
- package/city/web/dist/sprites/building-c.png +0 -0
- package/city/web/dist/sprites/building-d.png +0 -0
- package/city/web/dist/sprites/building-e.png +0 -0
- package/city/web/dist/sprites/building-f.png +0 -0
- package/city/web/dist/sprites/building-g.png +0 -0
- package/city/web/dist/sprites/building-h.png +0 -0
- package/city/web/dist/sprites/building-i.png +0 -0
- package/city/web/dist/sprites/building-j.png +0 -0
- package/city/web/dist/sprites/building-k.png +0 -0
- package/city/web/dist/sprites/building-l.png +0 -0
- package/city/web/dist/sprites/building-m.png +0 -0
- package/city/web/dist/sprites/building-n.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-a.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-b.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-c.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-d.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-e.png +0 -0
- package/city/web/dist/sprites/catalogo.json +47 -0
- package/city/web/dist/sprites/detail-awning-wide.png +0 -0
- package/city/web/dist/sprites/detail-awning.png +0 -0
- package/city/web/dist/sprites/detail-overhang-wide.png +0 -0
- package/city/web/dist/sprites/detail-overhang.png +0 -0
- package/city/web/dist/sprites/detail-parasol-a.png +0 -0
- package/city/web/dist/sprites/detail-parasol-b.png +0 -0
- package/city/web/dist/sprites/low-detail-building-a.png +0 -0
- package/city/web/dist/sprites/low-detail-building-b.png +0 -0
- package/city/web/dist/sprites/low-detail-building-c.png +0 -0
- package/city/web/dist/sprites/low-detail-building-d.png +0 -0
- package/city/web/dist/sprites/low-detail-building-e.png +0 -0
- package/city/web/dist/sprites/low-detail-building-f.png +0 -0
- package/city/web/dist/sprites/low-detail-building-g.png +0 -0
- package/city/web/dist/sprites/low-detail-building-h.png +0 -0
- package/city/web/dist/sprites/low-detail-building-i.png +0 -0
- package/city/web/dist/sprites/low-detail-building-j.png +0 -0
- package/city/web/dist/sprites/low-detail-building-k.png +0 -0
- package/city/web/dist/sprites/low-detail-building-l.png +0 -0
- package/city/web/dist/sprites/low-detail-building-m.png +0 -0
- package/city/web/dist/sprites/low-detail-building-n.png +0 -0
- package/city/web/dist/sprites/low-detail-building-wide-a.png +0 -0
- package/city/web/dist/sprites/low-detail-building-wide-b.png +0 -0
- package/city/web/dist/sprites/medidas.json +248 -0
- package/city/web/dist-hall/hall.js +264 -0
- package/city/web/index.html +294 -0
- package/city/web/package.json +18 -0
- package/city/web/sella.py +21 -0
- package/city/web/src/activity-actors.ts +47 -0
- package/city/web/src/activity.ts +172 -0
- package/city/web/src/ayuntamiento.ts +580 -0
- package/city/web/src/draw.ts +640 -0
- package/city/web/src/game-speech.ts +79 -0
- package/city/web/src/hall.ts +1867 -0
- package/city/web/src/main.ts +2225 -0
- package/city/web/src/oven.ts +124 -0
- package/city/web/src/people.ts +244 -0
- package/city/web/src/presencia.ts +193 -0
- package/city/web/src/puertas.ts +180 -0
- package/city/web/src/repo-roles.ts +59 -0
- package/city/worker/package.json +16 -0
- package/city/worker/schema.sql +117 -0
- package/city/worker/src/index.ts +702 -0
- package/city/worker/src/square.ts +218 -0
- package/city/worker/wrangler.toml +58 -0
- package/demo/ada.md +50 -0
- package/demo/bruno.md +62 -0
- package/demo/camila.md +56 -0
- package/demo/city/ada.md +40 -0
- package/demo/city/city.yml +8 -0
- package/demo/city/parcels.yml +15 -0
- package/demo/city/roads.json +4 -0
- package/demo/city/units.yml +10 -0
- package/demo/clinica/city.yml +8 -0
- package/demo/clinica/parcels.yml +15 -0
- package/demo/clinica/roads.json +4 -0
- package/demo/clinica/units.yml +9 -0
- package/demo/clinica/vera.md +46 -0
- package/demo/dante.md +54 -0
- package/demo/despacho/city.yml +8 -0
- package/demo/despacho/marta.md +46 -0
- package/demo/despacho/parcels.yml +15 -0
- package/demo/despacho/roads.json +4 -0
- package/demo/despacho/units.yml +9 -0
- package/demo/elsa.md +50 -0
- package/demo/farid.md +52 -0
- package/demo/greta.md +50 -0
- package/demo/hugo.md +49 -0
- package/demo/iris.md +49 -0
- package/demo/jonas.md +51 -0
- package/demo/kira.md +51 -0
- package/demo/luca.md +49 -0
- package/demo/parcels.yml +92 -0
- package/demo/seed.py +20 -0
- package/demo/show.py +161 -0
- package/demo/stories.py +824 -0
- package/demo/units.yml +33 -0
- package/docs/agents-first.md +99 -0
- package/docs/glossary.md +55 -0
- package/docs/map-live-layers.md +73 -0
- package/docs/security.md +231 -0
- package/docs/self-host.md +151 -0
- package/docs/testing.md +205 -0
- package/package.json +73 -0
- package/plugin/.claude-plugin/plugin.json +62 -0
- package/plugin/.mcp.json +15 -0
- package/plugin/channel/activity-cli.ts +16 -0
- package/plugin/channel/adapter-prompts.ts +113 -0
- package/plugin/channel/adapter.js +4292 -0
- package/plugin/channel/adapter.ts +133 -0
- package/plugin/channel/bus.js +20188 -0
- package/plugin/channel/bus.ts +131 -0
- package/plugin/channel/city-config.ts +125 -0
- package/plugin/channel/claude-channel.ts +58 -0
- package/plugin/channel/cli-args.ts +75 -0
- package/plugin/channel/client.js +4200 -0
- package/plugin/channel/client.ts +38 -0
- package/plugin/channel/committee/activity.ts +256 -0
- package/plugin/channel/committee/collection.ts +154 -0
- package/plugin/channel/committee/decision.ts +175 -0
- package/plugin/channel/committee/floor.ts +135 -0
- package/plugin/channel/committee/guards.ts +30 -0
- package/plugin/channel/committee/history.ts +38 -0
- package/plugin/channel/committee/render.ts +102 -0
- package/plugin/channel/committee/service.ts +72 -0
- package/plugin/channel/committee/storage.ts +92 -0
- package/plugin/channel/committee/types.ts +163 -0
- package/plugin/channel/committee/view.ts +63 -0
- package/plugin/channel/committee-cli.ts +109 -0
- package/plugin/channel/delivery-metrics.ts +61 -0
- package/plugin/channel/delivery-queue.ts +118 -0
- package/plugin/channel/hub/activity-controller.ts +72 -0
- package/plugin/channel/hub/activity-feed.ts +169 -0
- package/plugin/channel/hub/committee-controller.ts +60 -0
- package/plugin/channel/hub/connections.ts +48 -0
- package/plugin/channel/hub/diagnostics.ts +67 -0
- package/plugin/channel/hub/envelope-validity.ts +109 -0
- package/plugin/channel/hub/envelopes.ts +66 -0
- package/plugin/channel/hub/lifecycle.ts +91 -0
- package/plugin/channel/hub/local-roads.ts +89 -0
- package/plugin/channel/hub/remote-roads.ts +165 -0
- package/plugin/channel/hub/road-controller.ts +99 -0
- package/plugin/channel/hub-client.ts +149 -0
- package/plugin/channel/local-hub.js +5995 -0
- package/plugin/channel/local-hub.ts +349 -0
- package/plugin/channel/map-reporter.ts +48 -0
- package/plugin/channel/package-lock.json +1731 -0
- package/plugin/channel/package.json +19 -0
- package/plugin/channel/protocol.ts +98 -0
- package/plugin/channel/road-cli.ts +18 -0
- package/plugin/channel/run.sh +15 -0
- package/plugin/channel/runtime/claude.ts +458 -0
- package/plugin/channel/runtime/codex-config.ts +77 -0
- package/plugin/channel/runtime/codex.ts +669 -0
- package/plugin/channel/runtime/command.ts +62 -0
- package/plugin/channel/runtime/factory.ts +15 -0
- package/plugin/channel/runtime/json-rpc.ts +154 -0
- package/plugin/channel/runtime/kimi.ts +277 -0
- package/plugin/channel/runtime/opencode.ts +248 -0
- package/plugin/channel/runtime/process.ts +84 -0
- package/plugin/channel/runtime/types.ts +45 -0
- package/plugin/channel/runtime-files.ts +73 -0
- package/plugin/channel/runtime-gateway.js +6396 -0
- package/plugin/channel/runtime-gateway.ts +358 -0
- package/plugin/channel/runtime-metrics.ts +52 -0
- package/plugin/channel/runtime-subscription.ts +180 -0
- package/plugin/channel/terminal-delivery.ts +131 -0
- package/plugin/channel/untrusted.ts +60 -0
- package/plugin/commands/committee.md +31 -0
- package/plugin/commands/exit.md +32 -0
- package/plugin/commands/goals.md +20 -0
- package/plugin/commands/join.md +18 -0
- package/plugin/commands/notice.md +20 -0
- package/plugin/commands/propose.md +19 -0
- package/plugin/commands/round.md +17 -0
- package/plugin/commands/session.md +22 -0
- package/plugin/commands/settings.md +25 -0
- package/plugin/commands/setup.md +21 -0
- package/plugin/commands/team.md +23 -0
- package/plugin/domains/custom.md +27 -0
- package/plugin/domains/finance.md +31 -0
- package/plugin/domains/healthcare.md +34 -0
- package/plugin/domains/legal.md +31 -0
- package/plugin/domains/marketing.md +33 -0
- package/plugin/domains/operations.md +31 -0
- package/plugin/domains/research.md +32 -0
- package/plugin/domains/sales.md +30 -0
- package/plugin/domains/software.md +37 -0
- package/plugin/hooks/activity.sh +6 -0
- package/plugin/hooks/digging.sh +68 -0
- package/plugin/hooks/growth.sh +42 -0
- package/plugin/hooks/hooks.json +106 -0
- package/plugin/hooks/notice-on-pr.sh +35 -0
- package/plugin/hooks/notice-on-stop.sh +80 -0
- package/plugin/hooks/notice-pending.sh +58 -0
- package/plugin/hooks/solo-en-ciudad.sh +23 -0
- package/plugin/hooks/tokens.sh +47 -0
- package/plugin/roles/examples/account-executive.md +25 -0
- package/plugin/roles/examples/ai-manager.md +34 -0
- package/plugin/roles/examples/associate.md +24 -0
- package/plugin/roles/examples/brand-lead.md +20 -0
- package/plugin/roles/examples/cfo.md +20 -0
- package/plugin/roles/examples/city-lead.md +25 -0
- package/plugin/roles/examples/clinical-director.md +26 -0
- package/plugin/roles/examples/clinical-ops.md +25 -0
- package/plugin/roles/examples/clinician.md +25 -0
- package/plugin/roles/examples/compliance.md +27 -0
- package/plugin/roles/examples/content.md +24 -0
- package/plugin/roles/examples/controller.md +27 -0
- package/plugin/roles/examples/cpto.md +41 -0
- package/plugin/roles/examples/customer-success.md +25 -0
- package/plugin/roles/examples/data-engineer.md +37 -0
- package/plugin/roles/examples/data.md +60 -0
- package/plugin/roles/examples/dev.md +37 -0
- package/plugin/roles/examples/devops.md +39 -0
- package/plugin/roles/examples/enablement.md +24 -0
- package/plugin/roles/examples/ethics.md +24 -0
- package/plugin/roles/examples/fin-analytics.md +27 -0
- package/plugin/roles/examples/health-compliance.md +25 -0
- package/plugin/roles/examples/health-data.md +26 -0
- package/plugin/roles/examples/knowledge.md +25 -0
- package/plugin/roles/examples/lifecycle.md +26 -0
- package/plugin/roles/examples/llm-engineer.md +34 -0
- package/plugin/roles/examples/managing-partner.md +20 -0
- package/plugin/roles/examples/methods.md +23 -0
- package/plugin/roles/examples/operations-lead.md +23 -0
- package/plugin/roles/examples/ops.md +24 -0
- package/plugin/roles/examples/patient-safety.md +26 -0
- package/plugin/roles/examples/performance.md +27 -0
- package/plugin/roles/examples/po.md +36 -0
- package/plugin/roles/examples/process-owner.md +23 -0
- package/plugin/roles/examples/product-design.md +39 -0
- package/plugin/roles/examples/program-manager.md +24 -0
- package/plugin/roles/examples/quality.md +25 -0
- package/plugin/roles/examples/research-director.md +24 -0
- package/plugin/roles/examples/research-ops.md +25 -0
- package/plugin/roles/examples/researcher.md +25 -0
- package/plugin/roles/examples/revenue-lead.md +25 -0
- package/plugin/roles/examples/revops.md +26 -0
- package/plugin/roles/examples/seo.md +27 -0
- package/plugin/roles/examples/specialist.md +24 -0
- package/plugin/scripts/admision.py +165 -0
- package/plugin/scripts/apaga.py +232 -0
- package/plugin/scripts/avatar.py +211 -0
- package/plugin/scripts/broker.py +531 -0
- package/plugin/scripts/cage.py +252 -0
- package/plugin/scripts/capabilities.py +197 -0
- package/plugin/scripts/card.py +312 -0
- package/plugin/scripts/cities.py +520 -0
- package/plugin/scripts/city-env.sh +90 -0
- package/plugin/scripts/city-runtime.sh +87 -0
- package/plugin/scripts/city-session.sh +526 -0
- package/plugin/scripts/city_env.py +67 -0
- package/plugin/scripts/crecimiento.py +140 -0
- package/plugin/scripts/deliberations.py +48 -0
- package/plugin/scripts/doctor.py +172 -0
- package/plugin/scripts/domains.py +201 -0
- package/plugin/scripts/evidencia.py +64 -0
- package/plugin/scripts/find-repos.sh +111 -0
- package/plugin/scripts/gh.py +159 -0
- package/plugin/scripts/hall_protocol.py +147 -0
- package/plugin/scripts/hook_activity.py +139 -0
- package/plugin/scripts/launch.py +94 -0
- package/plugin/scripts/logs.py +98 -0
- package/plugin/scripts/pairing.py +185 -0
- package/plugin/scripts/parcels.py +132 -0
- package/plugin/scripts/read-card.py +51 -0
- package/plugin/scripts/report.py +201 -0
- package/plugin/scripts/reset.py +180 -0
- package/plugin/scripts/roads.py +230 -0
- package/plugin/scripts/roles.py +274 -0
- package/plugin/scripts/runtime_log.py +69 -0
- package/plugin/scripts/runtime_processes.py +132 -0
- package/plugin/scripts/rutas.py +83 -0
- package/plugin/scripts/seat.py +1349 -0
- package/plugin/scripts/tokens.py +194 -0
- package/plugin/scripts/trust-repos.py +59 -0
- package/plugin/scripts/ui.py +280 -0
- package/plugin/scripts/units.py +80 -0
- package/plugin/scripts/workspace.py +364 -0
- package/plugin/skills/city/SKILL.md +191 -0
- package/templates/blank.md +39 -0
- package/templates/finance.md +44 -0
- package/templates/legal.md +45 -0
- package/templates/marketing.md +48 -0
- package/templates/product.md +45 -0
|
@@ -0,0 +1,1057 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The personal-city Hall server.
|
|
3
|
+
|
|
4
|
+
./bin/test-serve.py
|
|
5
|
+
|
|
6
|
+
It is the only process in this project that both listens on a socket and writes
|
|
7
|
+
to disk, so its unhappy paths are security: no run token means no response, and a
|
|
8
|
+
foreign origin cannot drive it. The happy path pins one owner seat, several local
|
|
9
|
+
cities, symmetric roads and city-scoped writes.
|
|
10
|
+
|
|
11
|
+
The server runs in-process on an ephemeral port. No browser, no network beyond
|
|
12
|
+
loopback, and every city it writes lands in a temporary directory.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import threading
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
24
|
+
|
|
25
|
+
AQUI = os.path.dirname(os.path.abspath(__file__))
|
|
26
|
+
RAIZ = os.path.dirname(AQUI)
|
|
27
|
+
sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
|
|
28
|
+
sys.path.insert(0, AQUI)
|
|
29
|
+
import serve # noqa: E402
|
|
30
|
+
import card # noqa: E402
|
|
31
|
+
|
|
32
|
+
from testlib import comprueba, afirma, resumen # noqa: E402
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def pide(puerto, ruta, con_pase=True, metodo="GET", cuerpo=None, cabeceras=None):
|
|
36
|
+
"""One request against the test server. Returns (status, body-bytes)."""
|
|
37
|
+
url = f"http://127.0.0.1:{puerto}{ruta}"
|
|
38
|
+
if con_pase:
|
|
39
|
+
url += ("&" if "?" in ruta else "?") + "PASE=" + serve.PASE
|
|
40
|
+
req = urllib.request.Request(
|
|
41
|
+
url, method=metodo, data=json.dumps(cuerpo).encode() if cuerpo is not None else None
|
|
42
|
+
)
|
|
43
|
+
for k, v in (cabeceras or {}).items():
|
|
44
|
+
req.add_header(k, v)
|
|
45
|
+
try:
|
|
46
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
47
|
+
return r.status, r.read()
|
|
48
|
+
except urllib.error.HTTPError as e:
|
|
49
|
+
return e.code, e.read()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main():
|
|
53
|
+
print()
|
|
54
|
+
destino = tempfile.mkdtemp()
|
|
55
|
+
serve.DESTINO = os.path.join(destino, "ciudad")
|
|
56
|
+
|
|
57
|
+
servidor = serve.Servidor(("127.0.0.1", 0), serve.Manejador)
|
|
58
|
+
puerto = servidor.server_port
|
|
59
|
+
hilo = threading.Thread(target=servidor.serve_forever, daemon=True)
|
|
60
|
+
hilo.start()
|
|
61
|
+
|
|
62
|
+
# The hall reads whatever city AGENTS_CITY_DATA points at; give it one of its
|
|
63
|
+
# own so this suite never touches the caller's, and an empty disk index so
|
|
64
|
+
# /api/misrepos cannot wander off crawling the real home directory.
|
|
65
|
+
hallDatos = tempfile.mkdtemp()
|
|
66
|
+
appDatos = tempfile.mkdtemp()
|
|
67
|
+
arbitraryDatos = tempfile.mkdtemp()
|
|
68
|
+
# The suite must never write into the caller's real city registry.
|
|
69
|
+
serve.cities.REGISTRO = os.path.join(tempfile.mkdtemp(), "cities")
|
|
70
|
+
os.environ["AGENTS_CITY_DATA"] = hallDatos
|
|
71
|
+
os.environ["AGENTS_CITY_HOME"] = appDatos
|
|
72
|
+
os.environ["AGENTS_CITY_USER"] = "halltest"
|
|
73
|
+
os.environ["CITY_SEARCH_IN"] = tempfile.mkdtemp()
|
|
74
|
+
os.environ["XDG_CACHE_HOME"] = tempfile.mkdtemp()
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
# ── the doors that must be closed ──────────────────────────────────
|
|
78
|
+
print(" what must be refused")
|
|
79
|
+
st, _ = pide(puerto, "/", con_pase=False)
|
|
80
|
+
comprueba("· no token: nothing, not even the page", st, 403)
|
|
81
|
+
st, _ = pide(puerto, "/?PASE=wrong-token-entirely", con_pase=False)
|
|
82
|
+
comprueba("· a wrong token is the same as none", st, 403)
|
|
83
|
+
st, _ = pide(puerto, "/api/estado", con_pase=False)
|
|
84
|
+
comprueba("· the API refuses without it too", st, 403)
|
|
85
|
+
st, _ = pide(puerto, "/api/ficha", con_pase=False, metodo="POST", cuerpo={})
|
|
86
|
+
comprueba("· and above all the endpoint that writes to disk", st, 403)
|
|
87
|
+
# This server writes files: a page from any other origin must not drive it.
|
|
88
|
+
st, _ = pide(puerto, "/api/estado", cabeceras={"Origin": "http://evil.example"})
|
|
89
|
+
comprueba("· a foreign Origin is refused even with the token", st, 403)
|
|
90
|
+
st, _ = pide(puerto, "/api/estado", cabeceras={"Origin": f"http://127.0.0.1:{puerto}"})
|
|
91
|
+
comprueba("· our own origin is not", st, 200)
|
|
92
|
+
st, _ = pide(puerto, "/api/no-such-thing")
|
|
93
|
+
comprueba("· an unknown route is a 404, not a stack trace", st, 404)
|
|
94
|
+
# The demo's remote control must never touch a real city: a replay on a
|
|
95
|
+
# real bus would be publishing fiction as committee history.
|
|
96
|
+
st, _ = pide(puerto, "/api/demo", metodo="POST", cuerpo={"action": "restart"})
|
|
97
|
+
comprueba("· the demo replay refuses any city that is not the demo", st, 403)
|
|
98
|
+
st, cuerpo = pide(puerto, "/api/estado?city=" + urllib.parse.quote(arbitraryDatos, safe=""))
|
|
99
|
+
estado_seguro = json.loads(cuerpo)
|
|
100
|
+
afirma(
|
|
101
|
+
"· a crafted city path cannot turn an arbitrary folder into a city",
|
|
102
|
+
st == 200
|
|
103
|
+
and estado_seguro["datos"] == os.path.realpath(hallDatos)
|
|
104
|
+
and not os.path.exists(os.path.join(arbitraryDatos, "city.yml")),
|
|
105
|
+
)
|
|
106
|
+
st, cuerpo = pide(
|
|
107
|
+
puerto, "/api/roads", metodo="POST", cuerpo=None, cabeceras={"Content-Length": "0"}
|
|
108
|
+
)
|
|
109
|
+
afirma("· an empty POST body is handled, not a crash", st in (200, 400), f"status {st}")
|
|
110
|
+
|
|
111
|
+
# ── the happy path: the Hall and its role catalogue ─────────────────
|
|
112
|
+
print(" what must work")
|
|
113
|
+
st, cuerpo = pide(puerto, "/")
|
|
114
|
+
comprueba("· the page is served", st, 200)
|
|
115
|
+
afirma(
|
|
116
|
+
"· with the run token injected, not the placeholder",
|
|
117
|
+
serve.PASE.encode() in cuerpo and b"__PASE__" not in cuerpo,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
st, cuerpo = pide(puerto, "/api/roles?kind=product")
|
|
121
|
+
rolesR = json.loads(cuerpo)["roles"]
|
|
122
|
+
comprueba("· the product city offers its ten roles, including blank", len(rolesR), 10)
|
|
123
|
+
afirma(
|
|
124
|
+
"· with the trade read from the role files",
|
|
125
|
+
any(r["trade"] == "Architect" for r in rolesR),
|
|
126
|
+
)
|
|
127
|
+
afirma(
|
|
128
|
+
"· blank is visible but carries no invented trade",
|
|
129
|
+
any(r["id"] == "blank" and r["trade"] == "No preset" for r in rolesR),
|
|
130
|
+
str(rolesR),
|
|
131
|
+
)
|
|
132
|
+
st, cuerpo = pide(puerto, "/api/domains")
|
|
133
|
+
dominios = json.loads(cuerpo)["domains"]
|
|
134
|
+
afirma(
|
|
135
|
+
"· the Hall gets the same domain-first catalogue as the terminal",
|
|
136
|
+
st == 200
|
|
137
|
+
and dominios[0]["id"] == "software"
|
|
138
|
+
and any(d["id"] == "healthcare" for d in dominios),
|
|
139
|
+
str(dominios),
|
|
140
|
+
)
|
|
141
|
+
roles_salud = json.loads(pide(puerto, "/api/roles?domain=healthcare")[1])["roles"]
|
|
142
|
+
afirma(
|
|
143
|
+
"· selecting medicine replaces the software role grid",
|
|
144
|
+
roles_salud[0]["id"] == "clinical-director"
|
|
145
|
+
and not any(r["id"] == "dev" for r in roles_salud),
|
|
146
|
+
str(roles_salud),
|
|
147
|
+
)
|
|
148
|
+
roles_agente = json.loads(pide(puerto, "/api/roles?scope=agent")[1])["roles"]
|
|
149
|
+
ids_agente = {r["id"] for r in roles_agente}
|
|
150
|
+
afirma(
|
|
151
|
+
"· a repo agent can adopt a role outside the city domain",
|
|
152
|
+
{"blank", "seo", "po", "data-engineer"} <= ids_agente
|
|
153
|
+
and any("marketing" in r["domains"] for r in roles_agente if r["id"] == "seo"),
|
|
154
|
+
str(roles_agente),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# ── the town hall ───────────────────────────────────────────────────
|
|
158
|
+
print(" the town hall")
|
|
159
|
+
st, cuerpo = pide(puerto, "/")
|
|
160
|
+
afirma(
|
|
161
|
+
"· / serves the hall, token injected",
|
|
162
|
+
st == 200 and b"town hall" in cuerpo and b"__PASE__" not in cuerpo,
|
|
163
|
+
)
|
|
164
|
+
afirma(
|
|
165
|
+
"· the spectator panel is a moderated conversation with avatars and quiet work",
|
|
166
|
+
b"A moderated conversation over the WebSocket bus" in cuerpo
|
|
167
|
+
and b'id="liveWorkToggle"' in cuerpo
|
|
168
|
+
and b'id="liveContext"' in cuerpo
|
|
169
|
+
and b".liveAvatar" in cuerpo,
|
|
170
|
+
cuerpo[:500].decode(errors="replace"),
|
|
171
|
+
)
|
|
172
|
+
map_html = open(os.path.join(RAIZ, "city", "web", "index.html")).read()
|
|
173
|
+
hall_source = open(os.path.join(RAIZ, "city", "web", "src", "hall.ts")).read()
|
|
174
|
+
speech_source = open(os.path.join(RAIZ, "city", "web", "src", "game-speech.ts")).read()
|
|
175
|
+
afirma(
|
|
176
|
+
"· the city owns the whole central canvas; persistent HUD stays in sidebars",
|
|
177
|
+
'<main aria-label="City map"></main>' in map_html
|
|
178
|
+
and map_html.index('id="cinta"') < map_html.index('<main aria-label="City map"')
|
|
179
|
+
and map_html.index('id="mandos"') < map_html.index('<main aria-label="City map"')
|
|
180
|
+
and map_html.index('id="peli"') < map_html.index('<main aria-label="City map"')
|
|
181
|
+
and map_html.index('id="ficha"') > map_html.index('<aside id="avisos"')
|
|
182
|
+
and "#cinta{position:absolute" not in map_html
|
|
183
|
+
and "#peli{position:absolute" not in map_html,
|
|
184
|
+
)
|
|
185
|
+
afirma(
|
|
186
|
+
"· the Hall embeds only the canvas and forwards the same live WebSocket event",
|
|
187
|
+
"let SECCION = 'mapa'" in hall_source
|
|
188
|
+
and "mapUrl.searchParams.set('embed', '1')" in hall_source
|
|
189
|
+
and "mapUrl.searchParams.set('parent_origin', location.origin)" in hall_source
|
|
190
|
+
and "MAP_ACTIVITY_PROTOCOL" in hall_source
|
|
191
|
+
and "enviaActividadAlMapa(event)" in hall_source,
|
|
192
|
+
)
|
|
193
|
+
afirma(
|
|
194
|
+
"· game speech is anchored to a real actor and starts with its recipient",
|
|
195
|
+
"speech.target.getBounds()" in speech_source
|
|
196
|
+
and "`Para ${speechRecipient(event)}:`" in speech_source
|
|
197
|
+
and "compactSpeech(event.summary)" in speech_source,
|
|
198
|
+
)
|
|
199
|
+
st, cuerpo = pide(puerto, "/hall.js")
|
|
200
|
+
if st == 200:
|
|
201
|
+
afirma(
|
|
202
|
+
"· the hall code is served, typed and built",
|
|
203
|
+
b"town hall" in cuerpo or len(cuerpo) > 1000,
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
afirma(
|
|
207
|
+
"· an unbuilt hall says how to build it, not just 404",
|
|
208
|
+
st == 404 and b"npm" in cuerpo,
|
|
209
|
+
f"{st}: {cuerpo[:120]!r}",
|
|
210
|
+
)
|
|
211
|
+
# And the unbuilt branch, deterministically.
|
|
212
|
+
js_real = serve.HALL_JS
|
|
213
|
+
serve.HALL_JS = "/definitely/not/built/hall.js"
|
|
214
|
+
st, cuerpo = pide(puerto, "/hall.js")
|
|
215
|
+
afirma(
|
|
216
|
+
"· missing build is a 404 with the build command",
|
|
217
|
+
st == 404 and b"npm run build" in cuerpo,
|
|
218
|
+
f"{st}: {cuerpo[:120]!r}",
|
|
219
|
+
)
|
|
220
|
+
serve.HALL_JS = js_real
|
|
221
|
+
|
|
222
|
+
st, cuerpo = pide(puerto, "/wizard")
|
|
223
|
+
afirma("· the v1 multiple-person wizard is retired", st == 404 and b"retired" in cuerpo)
|
|
224
|
+
st, _ = pide(puerto, "/api/escribe", metodo="POST", cuerpo={})
|
|
225
|
+
comprueba("· its multiple-person write endpoint is retired too", st, 404)
|
|
226
|
+
|
|
227
|
+
st, cuerpo = pide(puerto, "/api/estado")
|
|
228
|
+
estado = json.loads(cuerpo)
|
|
229
|
+
comprueba("· estado answers for an empty city", st, 200)
|
|
230
|
+
comprueba(
|
|
231
|
+
"· with no cards, no houses, no districts",
|
|
232
|
+
(estado["tarjetas"], estado["parcelas"], estado["unidades"]),
|
|
233
|
+
([], [], []),
|
|
234
|
+
)
|
|
235
|
+
comprueba("· and no invented committee history", estado["deliberations"], [])
|
|
236
|
+
afirma("· and it knows who is asking", bool(estado["yo"]), repr(estado["yo"]))
|
|
237
|
+
owner, address = estado["yo"], estado["address"]
|
|
238
|
+
afirma(
|
|
239
|
+
"· its public road invitation contains no credential",
|
|
240
|
+
estado["invitation"]["address"] == address
|
|
241
|
+
and estado["invitation"]["domain"] == "software"
|
|
242
|
+
and not any("token" in k.lower() for k in estado["invitation"]),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Committee acts are read-only local state in the Hall.
|
|
246
|
+
act_id = "delib_20260826120000_abcd1234"
|
|
247
|
+
act_dir = os.path.join(hallDatos, "deliberations", act_id)
|
|
248
|
+
os.makedirs(act_dir)
|
|
249
|
+
open(os.path.join(act_dir, "ACT.md"), "w").write("# Ship?\n")
|
|
250
|
+
open(os.path.join(act_dir, "state.json"), "w").write(
|
|
251
|
+
json.dumps(
|
|
252
|
+
{
|
|
253
|
+
"schema": "agents-city/deliberation@1",
|
|
254
|
+
"id": act_id,
|
|
255
|
+
"status": "verifying",
|
|
256
|
+
"updatedAt": "2026-08-26T12:00:00Z",
|
|
257
|
+
"brief": {
|
|
258
|
+
"question": "Ship?",
|
|
259
|
+
"desiredOutcome": "A verified answer",
|
|
260
|
+
"participants": ["api", "web"],
|
|
261
|
+
},
|
|
262
|
+
"positions": {"api": {}},
|
|
263
|
+
"progress": {"revision": 2},
|
|
264
|
+
"decisions": [
|
|
265
|
+
{
|
|
266
|
+
"outcome": "Ship after replay",
|
|
267
|
+
"verifier": "ops",
|
|
268
|
+
"decisiveContributors": ["api"],
|
|
269
|
+
}
|
|
270
|
+
],
|
|
271
|
+
}
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
acts = json.loads(pide(puerto, "/api/estado")[1])["deliberations"]
|
|
275
|
+
afirma(
|
|
276
|
+
"· the Hall shows durable committee progress without mutating it",
|
|
277
|
+
len(acts) == 1
|
|
278
|
+
and acts[0]["id"] == act_id
|
|
279
|
+
and acts[0]["received"] == 1
|
|
280
|
+
and acts[0]["verifier"] == "ops"
|
|
281
|
+
and acts[0]["contributors"] == ["api"],
|
|
282
|
+
str(acts),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# A seat written through the hall is the same card as everywhere else.
|
|
286
|
+
st, cuerpo = pide(
|
|
287
|
+
puerto,
|
|
288
|
+
"/api/ficha",
|
|
289
|
+
metodo="POST",
|
|
290
|
+
cuerpo={
|
|
291
|
+
"user": "Ana.López@x.com",
|
|
292
|
+
"domain": "healthcare",
|
|
293
|
+
"role": "clinical-director",
|
|
294
|
+
"repos": ["api"],
|
|
295
|
+
"repo_roles": {"api": "seo"},
|
|
296
|
+
"objetivo": {
|
|
297
|
+
"title": "One goal",
|
|
298
|
+
"signal": "s",
|
|
299
|
+
"command": "",
|
|
300
|
+
"baseline": "0",
|
|
301
|
+
"target": "1",
|
|
302
|
+
"by": "Q3",
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
)
|
|
306
|
+
r = json.loads(cuerpo)
|
|
307
|
+
comprueba("· a card is written through the hall", st, 200)
|
|
308
|
+
comprueba("· body.user cannot create a second person in the city", r["user"], owner)
|
|
309
|
+
c = card.lee(os.path.join(hallDatos, f"{owner}.md"))
|
|
310
|
+
comprueba(
|
|
311
|
+
"· and reads back through the shared reader",
|
|
312
|
+
(c["agent"], c["role"], c["repos"], c["repo_roles"]),
|
|
313
|
+
(address, "clinical-director", ["api"], {"api": "seo"}),
|
|
314
|
+
)
|
|
315
|
+
estado_salud = json.loads(pide(puerto, "/api/estado")[1])
|
|
316
|
+
afirma(
|
|
317
|
+
"· domain and role knowledge land transparently in the city",
|
|
318
|
+
estado_salud["domain"] == "healthcare"
|
|
319
|
+
and os.path.isfile(os.path.join(hallDatos, "domains", "healthcare.md"))
|
|
320
|
+
and os.path.isfile(os.path.join(hallDatos, "roles", "clinical-director.md"))
|
|
321
|
+
and os.path.isfile(os.path.join(hallDatos, "roles", "seo.md")),
|
|
322
|
+
)
|
|
323
|
+
afirma(
|
|
324
|
+
"· the live capability view exposes the repo agent role",
|
|
325
|
+
estado_salud["skills"]["api"]["role"] == "seo",
|
|
326
|
+
str(estado_salud["skills"]),
|
|
327
|
+
)
|
|
328
|
+
comprueba(
|
|
329
|
+
"· roads can route by public domain and seat role",
|
|
330
|
+
(estado_salud["invitation"]["domain"], estado_salud["invitation"]["role"]),
|
|
331
|
+
("healthcare", "clinical-director"),
|
|
332
|
+
)
|
|
333
|
+
comprueba(
|
|
334
|
+
"· an empty command is manual, not an empty string to run", c["objetivo"]["command"], ""
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
st, cuerpo = pide(
|
|
338
|
+
puerto,
|
|
339
|
+
"/api/ficha",
|
|
340
|
+
metodo="POST",
|
|
341
|
+
cuerpo={
|
|
342
|
+
"domain": "healthcare",
|
|
343
|
+
"role": "clinical-director",
|
|
344
|
+
"repos": ["api"],
|
|
345
|
+
"repo_roles": [],
|
|
346
|
+
},
|
|
347
|
+
)
|
|
348
|
+
afirma(
|
|
349
|
+
"· malformed repo-role mappings are a visible 400",
|
|
350
|
+
st == 400 and "repo_roles must be an object" in json.loads(cuerpo)["error"],
|
|
351
|
+
cuerpo.decode(),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# A goal with no title is no goal, not a goal called nothing.
|
|
355
|
+
pide(
|
|
356
|
+
puerto,
|
|
357
|
+
"/api/ficha",
|
|
358
|
+
metodo="POST",
|
|
359
|
+
cuerpo={
|
|
360
|
+
"user": "bo@x.com",
|
|
361
|
+
"domain": "healthcare",
|
|
362
|
+
"role": "clinical-director",
|
|
363
|
+
"repos": [],
|
|
364
|
+
"objetivo": {"title": " "},
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
comprueba(
|
|
368
|
+
"· a blank goal title means no goal",
|
|
369
|
+
card.lee(os.path.join(hallDatos, f"{owner}.md"))["objetivo"],
|
|
370
|
+
None,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
# Districts: the wrong shape is a 400 the page can show, the right one
|
|
374
|
+
# round-trips through the shared reader.
|
|
375
|
+
st, _ = pide(puerto, "/api/unidades", metodo="POST", cuerpo={"unidades": "banking"})
|
|
376
|
+
comprueba("· districts as a string is a 400, not a crash", st, 400)
|
|
377
|
+
st, _ = pide(
|
|
378
|
+
puerto, "/api/unidades", metodo="POST", cuerpo={"unidades": [{"name": "no id"}]}
|
|
379
|
+
)
|
|
380
|
+
comprueba("· a district with no id, likewise", st, 400)
|
|
381
|
+
st, cuerpo = pide(
|
|
382
|
+
puerto,
|
|
383
|
+
"/api/unidades",
|
|
384
|
+
metodo="POST",
|
|
385
|
+
cuerpo={"unidades": [{"id": "Banca Digital", "name": "Banca", "color": "#3FB8A0"}]},
|
|
386
|
+
)
|
|
387
|
+
r = json.loads(cuerpo)
|
|
388
|
+
comprueba("· a real district is written and cleaned", st, 200)
|
|
389
|
+
comprueba(
|
|
390
|
+
"· id slugged, colour lowered",
|
|
391
|
+
(r["unidades"][0]["id"], r["unidades"][0]["color"]),
|
|
392
|
+
("banca-digital", "3fb8a0"),
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Houses: same deal.
|
|
396
|
+
st, _ = pide(puerto, "/api/parcelas", metodo="POST", cuerpo={"repos": ["x"]})
|
|
397
|
+
comprueba("· houses as a list is a 400", st, 400)
|
|
398
|
+
st, cuerpo = pide(
|
|
399
|
+
puerto,
|
|
400
|
+
"/api/parcelas",
|
|
401
|
+
metodo="POST",
|
|
402
|
+
cuerpo={
|
|
403
|
+
"repos": {
|
|
404
|
+
"api": [
|
|
405
|
+
{"ruta": "src/**", "unidad": "banca-digital", "nombre": "api · src"},
|
|
406
|
+
{"ruta": "", "unidad": "none", "nombre": "api · rest"},
|
|
407
|
+
]
|
|
408
|
+
},
|
|
409
|
+
"lab": ["lab-x"],
|
|
410
|
+
},
|
|
411
|
+
)
|
|
412
|
+
r = json.loads(cuerpo)
|
|
413
|
+
comprueba(
|
|
414
|
+
"· a split repo round-trips through the shared reader",
|
|
415
|
+
[(p["id"], p["unidad"]) for p in r["parcelas"]],
|
|
416
|
+
[("api:src/**", "banca-digital"), ("api", "none")],
|
|
417
|
+
)
|
|
418
|
+
# And estado now reflects all of it — the page repaints from this.
|
|
419
|
+
estado = json.loads(pide(puerto, "/api/estado")[1])
|
|
420
|
+
comprueba(
|
|
421
|
+
"· estado sees one seat, two houses, one district",
|
|
422
|
+
(
|
|
423
|
+
len(estado["tarjetas"]),
|
|
424
|
+
len(estado["parcelas"]),
|
|
425
|
+
len(estado["unidades"]),
|
|
426
|
+
estado["lab"],
|
|
427
|
+
),
|
|
428
|
+
(1, 2, 1, ["lab-x"]),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# ── several cities and their roads through one hall ────────────────
|
|
432
|
+
st, cuerpo = pide(puerto, "/api/ciudades", metodo="POST", cuerpo={"name": "Otra"})
|
|
433
|
+
creada = json.loads(cuerpo)
|
|
434
|
+
afirma(
|
|
435
|
+
"· the hall creates another city below the same user",
|
|
436
|
+
st == 200 and creada.get("address") == "halltest/otra",
|
|
437
|
+
str(creada),
|
|
438
|
+
)
|
|
439
|
+
otra = creada["city"]
|
|
440
|
+
est1 = json.loads(pide(puerto, "/api/estado")[1])
|
|
441
|
+
est2 = json.loads(pide(puerto, "/api/estado?city=" + otra)[1])
|
|
442
|
+
comprueba(
|
|
443
|
+
"· ?city= switches which city the hall manages",
|
|
444
|
+
(est1["datos"] != est2["datos"], os.path.realpath(otra) == est2["datos"]),
|
|
445
|
+
(True, True),
|
|
446
|
+
)
|
|
447
|
+
afirma(
|
|
448
|
+
"· the session name follows the city, so attach commands are right",
|
|
449
|
+
est2["sesion"].endswith("-otra") and not est1["sesion"].endswith("-otra"),
|
|
450
|
+
f"{est1['sesion']} / {est2['sesion']}",
|
|
451
|
+
)
|
|
452
|
+
afirma(
|
|
453
|
+
"· and the switcher gets the list with the current one marked",
|
|
454
|
+
any(c["actual"] for c in est2["ciudades"]),
|
|
455
|
+
)
|
|
456
|
+
st, cuerpo = pide(
|
|
457
|
+
puerto,
|
|
458
|
+
"/api/roads",
|
|
459
|
+
metodo="POST",
|
|
460
|
+
cuerpo={"action": "connect", "target": est2["city_id"]},
|
|
461
|
+
)
|
|
462
|
+
r = json.loads(cuerpo)
|
|
463
|
+
afirma(
|
|
464
|
+
"· opening a local road writes the current endpoint",
|
|
465
|
+
st == 200 and r["roads"][0]["address"] == "halltest/otra",
|
|
466
|
+
str(r),
|
|
467
|
+
)
|
|
468
|
+
otra_estado = json.loads(pide(puerto, "/api/estado?city=" + otra)[1])
|
|
469
|
+
afirma(
|
|
470
|
+
"· and the other endpoint is symmetric",
|
|
471
|
+
otra_estado["roads"][0]["id"] == est1["city_id"],
|
|
472
|
+
str(otra_estado["roads"]),
|
|
473
|
+
)
|
|
474
|
+
st, cuerpo = pide(
|
|
475
|
+
puerto,
|
|
476
|
+
"/api/roads",
|
|
477
|
+
metodo="POST",
|
|
478
|
+
cuerpo={"action": "disconnect", "target": est2["city_id"]},
|
|
479
|
+
)
|
|
480
|
+
afirma(
|
|
481
|
+
"· closing it removes both endpoints",
|
|
482
|
+
st == 200
|
|
483
|
+
and json.loads(cuerpo)["roads"] == []
|
|
484
|
+
and json.loads(pide(puerto, "/api/estado?city=" + otra)[1])["roads"] == [],
|
|
485
|
+
)
|
|
486
|
+
# A write follows the same request's city — never the default's.
|
|
487
|
+
pide(
|
|
488
|
+
puerto,
|
|
489
|
+
"/api/unidades?city=" + otra,
|
|
490
|
+
metodo="POST",
|
|
491
|
+
cuerpo={"unidades": [{"id": "u1", "name": "U1", "color": "aabbcc"}]},
|
|
492
|
+
)
|
|
493
|
+
afirma(
|
|
494
|
+
"· a save lands in the requested city, not the default",
|
|
495
|
+
os.path.exists(os.path.join(otra, "units.yml"))
|
|
496
|
+
and "u1" in open(os.path.join(otra, "units.yml")).read(),
|
|
497
|
+
)
|
|
498
|
+
afirma(
|
|
499
|
+
"· and the default city was not touched by it",
|
|
500
|
+
not os.path.exists(os.path.join(hallDatos, "units.yml"))
|
|
501
|
+
or "u1" not in open(os.path.join(hallDatos, "units.yml")).read(),
|
|
502
|
+
)
|
|
503
|
+
shutil.rmtree(otra)
|
|
504
|
+
|
|
505
|
+
# ── the demo's remote control ────────────────────────────────────────
|
|
506
|
+
print(" the demo's remote control")
|
|
507
|
+
# Which story a demo replays comes from its city.yml domain AS WRITTEN.
|
|
508
|
+
# The registry normalises unknown domains to software, which is exactly
|
|
509
|
+
# how the clinic once replayed Aurora's night instead of its own
|
|
510
|
+
# morning — this is that bug's tombstone.
|
|
511
|
+
comprueba(
|
|
512
|
+
"· each packaged demo city replays its own domain's story",
|
|
513
|
+
[
|
|
514
|
+
serve.historia_del_demo(os.path.join(RAIZ, "demo", d))
|
|
515
|
+
for d in ("city", "clinica", "despacho")
|
|
516
|
+
],
|
|
517
|
+
["software", "medicina", "legal"],
|
|
518
|
+
)
|
|
519
|
+
comprueba(
|
|
520
|
+
"· a demo with an unknown or missing domain falls back to software",
|
|
521
|
+
serve.historia_del_demo(tempfile.mkdtemp()),
|
|
522
|
+
"software",
|
|
523
|
+
)
|
|
524
|
+
# The control verbs, against a city dressed as a demo. `restart` is NOT
|
|
525
|
+
# exercised live on purpose: it spawns the real storyteller against the
|
|
526
|
+
# real bus; its story choice and its 403 on real cities are covered.
|
|
527
|
+
with open(os.path.join(hallDatos, "city.yml"), "w", encoding="utf-8") as f:
|
|
528
|
+
f.write("id: city_demo_test_v1\nname: Test Demo\nslug: test-demo\nowner: halltest\n")
|
|
529
|
+
st, _ = pide(puerto, "/api/demo", metodo="POST", cuerpo={"action": "volar"})
|
|
530
|
+
comprueba("· an unknown control verb is a 400, not a surprise", st, 400)
|
|
531
|
+
st, cuerpo_demo = pide(puerto, "/api/demo", metodo="POST", cuerpo={"action": "status"})
|
|
532
|
+
estado_demo = json.loads(cuerpo_demo)
|
|
533
|
+
afirma(
|
|
534
|
+
"· status answers honestly when nothing is playing",
|
|
535
|
+
st == 200 and estado_demo.get("ok") and not estado_demo.get("running"),
|
|
536
|
+
str(estado_demo),
|
|
537
|
+
)
|
|
538
|
+
st, cuerpo_demo = pide(puerto, "/api/demo", metodo="POST", cuerpo={"action": "pause"})
|
|
539
|
+
estado_demo = json.loads(cuerpo_demo)
|
|
540
|
+
afirma(
|
|
541
|
+
"· pausing silence is a safe no-op, never a crash",
|
|
542
|
+
st == 200 and not estado_demo.get("running") and not estado_demo.get("paused"),
|
|
543
|
+
str(estado_demo),
|
|
544
|
+
)
|
|
545
|
+
os.unlink(os.path.join(hallDatos, "city.yml"))
|
|
546
|
+
|
|
547
|
+
# ── the agents' character sheets ─────────────────────────────────────
|
|
548
|
+
print(" the agents' character sheets")
|
|
549
|
+
# An agents-first card: a knowledge agent with its workspace on disk,
|
|
550
|
+
# so discovery, growth and the engine keys all have something real.
|
|
551
|
+
taller = os.path.join(hallDatos, "agents", "notas")
|
|
552
|
+
os.makedirs(taller, exist_ok=True)
|
|
553
|
+
open(os.path.join(taller, "apuntes.md"), "w").write("# notas\n")
|
|
554
|
+
with open(os.path.join(hallDatos, "halltest.md"), "w", encoding="utf-8") as f:
|
|
555
|
+
f.write(
|
|
556
|
+
"---\n"
|
|
557
|
+
"user: halltest\nname: Hall Test\nrole: cpto\nagent: halltest/seat\n"
|
|
558
|
+
"agents: [notas]\nkind.notas: knowledge\nrole.notas: apuntes\n"
|
|
559
|
+
"runs.notas: claude\nmodel.notas: opus\neffort.notas: high\n"
|
|
560
|
+
"goals_defined: true\n---\n"
|
|
561
|
+
)
|
|
562
|
+
st, cuerpo = pide(puerto, "/api/estado")
|
|
563
|
+
estado = json.loads(cuerpo)
|
|
564
|
+
hoja = next((x for x in estado.get("agents", []) if x["name"] == "notas"), None)
|
|
565
|
+
afirma(
|
|
566
|
+
"· the sheet carries kind, engine and growth — all read, none invented",
|
|
567
|
+
st == 200
|
|
568
|
+
and hoja is not None
|
|
569
|
+
and hoja["kind"] == "knowledge"
|
|
570
|
+
and hoja["runtime"] == "claude"
|
|
571
|
+
and hoja["model"] == "opus"
|
|
572
|
+
and hoja["effort"] == "high"
|
|
573
|
+
and hoja["growth"]["floors"] >= 1,
|
|
574
|
+
str(hoja),
|
|
575
|
+
)
|
|
576
|
+
afirma(
|
|
577
|
+
"· an agents-first agent is discovered with a face and a skills entry",
|
|
578
|
+
"notas" in estado.get("avatars", {}) and "notas" in estado.get("skills", {}),
|
|
579
|
+
str(sorted(estado.get("skills", {}).keys())),
|
|
580
|
+
)
|
|
581
|
+
st, cuerpo = pide(
|
|
582
|
+
puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "model": "haiku"}
|
|
583
|
+
)
|
|
584
|
+
respuesta = json.loads(cuerpo)
|
|
585
|
+
carta = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
|
|
586
|
+
afirma(
|
|
587
|
+
"· happy: tuning the engine persists to the card key the launcher reads",
|
|
588
|
+
st == 200 and respuesta["agent"]["model"] == "haiku" and "model.notas: haiku" in carta,
|
|
589
|
+
carta,
|
|
590
|
+
)
|
|
591
|
+
st, cuerpo = pide(
|
|
592
|
+
puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "model": ""}
|
|
593
|
+
)
|
|
594
|
+
carta = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
|
|
595
|
+
afirma(
|
|
596
|
+
"· happy: an empty value removes the key — back to the owner's default",
|
|
597
|
+
st == 200 and "model.notas" not in carta,
|
|
598
|
+
carta,
|
|
599
|
+
)
|
|
600
|
+
st, _ = pide(
|
|
601
|
+
puerto, "/api/agente", metodo="POST", cuerpo={"agent": "nadie", "model": "opus"}
|
|
602
|
+
)
|
|
603
|
+
comprueba("· non-happy: an unknown agent is a 404, never a write", st, 404)
|
|
604
|
+
st, _ = pide(
|
|
605
|
+
puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "effort": "turbo"}
|
|
606
|
+
)
|
|
607
|
+
comprueba("· non-happy: an invented effort level is refused", st, 400)
|
|
608
|
+
st, _ = pide(
|
|
609
|
+
puerto,
|
|
610
|
+
"/api/agente",
|
|
611
|
+
metodo="POST",
|
|
612
|
+
cuerpo={"agent": "notas", "model": "opus; rm -rf /"},
|
|
613
|
+
)
|
|
614
|
+
comprueba("· non-happy: a model value is an alias, never a shell fragment", st, 400)
|
|
615
|
+
st, cuerpo = pide(
|
|
616
|
+
puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "runtime": "codex"}
|
|
617
|
+
)
|
|
618
|
+
carta = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
|
|
619
|
+
afirma(
|
|
620
|
+
"· happy: the provider is a card key too",
|
|
621
|
+
st == 200 and "runs.notas: codex" in carta,
|
|
622
|
+
carta,
|
|
623
|
+
)
|
|
624
|
+
st, _ = pide(
|
|
625
|
+
puerto,
|
|
626
|
+
"/api/agente",
|
|
627
|
+
metodo="POST",
|
|
628
|
+
cuerpo={"agent": "notas", "runtime": "terminal:sh"},
|
|
629
|
+
)
|
|
630
|
+
comprueba("· non-happy: the web never plants a terminal command", st, 400)
|
|
631
|
+
st, cuerpo = pide(
|
|
632
|
+
puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "avatar": "abc123"}
|
|
633
|
+
)
|
|
634
|
+
cara_nueva = json.loads(cuerpo)["agent"]["avatar"]
|
|
635
|
+
st2, cuerpo2 = pide(
|
|
636
|
+
puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "avatar": ""}
|
|
637
|
+
)
|
|
638
|
+
afirma(
|
|
639
|
+
"· happy: a face reroll is a seed on the card, and clearing it restores the classic",
|
|
640
|
+
st == 200
|
|
641
|
+
and st2 == 200
|
|
642
|
+
and cara_nueva != json.loads(cuerpo2)["agent"]["avatar"],
|
|
643
|
+
"",
|
|
644
|
+
)
|
|
645
|
+
st, _ = pide(
|
|
646
|
+
puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "avatar": "MAL$SEED"}
|
|
647
|
+
)
|
|
648
|
+
comprueba("· non-happy: an avatar seed is short and plain", st, 400)
|
|
649
|
+
# A 400 must never be an answer that already mutated the card: the
|
|
650
|
+
# handler once wrote field-by-field, so a valid model landed before an
|
|
651
|
+
# invalid effort was refused.
|
|
652
|
+
pide(puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "model": "sonnet"})
|
|
653
|
+
st, _ = pide(
|
|
654
|
+
puerto,
|
|
655
|
+
"/api/agente",
|
|
656
|
+
metodo="POST",
|
|
657
|
+
cuerpo={"agent": "notas", "model": "haiku", "effort": "turbo"},
|
|
658
|
+
)
|
|
659
|
+
carta = open(os.path.join(hallDatos, "halltest.md"), encoding="utf-8").read()
|
|
660
|
+
afirma(
|
|
661
|
+
"· non-happy: a refused edit leaves the whole card untouched",
|
|
662
|
+
st == 400 and "model.notas: sonnet" in carta and "haiku" not in carta,
|
|
663
|
+
carta,
|
|
664
|
+
)
|
|
665
|
+
pide(puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "model": ""})
|
|
666
|
+
|
|
667
|
+
# ── instructions and skills, into the agent's own home ──────────────
|
|
668
|
+
print(" instructions and skills, written only into the agent's own home")
|
|
669
|
+
st, cuerpo = pide(puerto, "/api/instrucciones?agent=notas&file=CLAUDE.md")
|
|
670
|
+
antes = json.loads(cuerpo)
|
|
671
|
+
afirma(
|
|
672
|
+
"· happy: a missing instruction file reads honestly as absent, with its reader",
|
|
673
|
+
st == 200 and antes["exists"] is False and antes["reader"] == "claude",
|
|
674
|
+
str(antes),
|
|
675
|
+
)
|
|
676
|
+
st, _ = pide(
|
|
677
|
+
puerto,
|
|
678
|
+
"/api/instrucciones",
|
|
679
|
+
metodo="POST",
|
|
680
|
+
cuerpo={"agent": "notas", "file": "CLAUDE.md", "content": "# Notas\nSé breve.\n"},
|
|
681
|
+
)
|
|
682
|
+
st2, cuerpo = pide(puerto, "/api/instrucciones?agent=notas&file=CLAUDE.md")
|
|
683
|
+
despues = json.loads(cuerpo)
|
|
684
|
+
afirma(
|
|
685
|
+
"· happy: written atomically into the workspace and read back verbatim",
|
|
686
|
+
st == 200 and st2 == 200 and despues["exists"] and "Sé breve." in despues["content"],
|
|
687
|
+
str(despues),
|
|
688
|
+
)
|
|
689
|
+
st, _ = pide(puerto, "/api/instrucciones?agent=notas&file=HACK.md")
|
|
690
|
+
comprueba("· non-happy: only CLAUDE.md and AGENTS.md exist here", st, 400)
|
|
691
|
+
# A latin-1 CLAUDE.md once escaped the handler as a UnicodeDecodeError
|
|
692
|
+
# and dropped the connection; and `exists: false` would be worse — the
|
|
693
|
+
# editor would save over what it could not read.
|
|
694
|
+
with open(os.path.join(taller, "CLAUDE.md"), "wb") as f:
|
|
695
|
+
f.write("Triaje: café\n".encode("latin-1"))
|
|
696
|
+
st, cuerpo = pide(puerto, "/api/instrucciones?agent=notas&file=CLAUDE.md")
|
|
697
|
+
afirma(
|
|
698
|
+
"· non-happy: a non-UTF-8 file is a JSON refusal, never a dropped connection",
|
|
699
|
+
st == 409 and "not UTF-8" in json.loads(cuerpo).get("error", ""),
|
|
700
|
+
cuerpo.decode(errors="replace"),
|
|
701
|
+
)
|
|
702
|
+
pide(
|
|
703
|
+
puerto,
|
|
704
|
+
"/api/instrucciones",
|
|
705
|
+
metodo="POST",
|
|
706
|
+
cuerpo={"agent": "notas", "file": "CLAUDE.md", "content": "# Notas\nSé breve.\n"},
|
|
707
|
+
)
|
|
708
|
+
st, _ = pide(
|
|
709
|
+
puerto,
|
|
710
|
+
"/api/instrucciones",
|
|
711
|
+
metodo="POST",
|
|
712
|
+
cuerpo={"agent": "nadie", "file": "CLAUDE.md", "content": "x"},
|
|
713
|
+
)
|
|
714
|
+
comprueba("· non-happy: an unknown agent gets no file", st, 404)
|
|
715
|
+
|
|
716
|
+
import base64 as _b64
|
|
717
|
+
import io as _io
|
|
718
|
+
import zipfile as _zip
|
|
719
|
+
|
|
720
|
+
def zipea(entradas, enlace=None):
|
|
721
|
+
crudo = _io.BytesIO()
|
|
722
|
+
with _zip.ZipFile(crudo, "w") as z:
|
|
723
|
+
for nombre, contenido in entradas:
|
|
724
|
+
z.writestr(nombre, contenido)
|
|
725
|
+
if enlace:
|
|
726
|
+
info = _zip.ZipInfo(enlace)
|
|
727
|
+
info.external_attr = (0o120777 << 16)
|
|
728
|
+
z.writestr(info, "/etc/passwd")
|
|
729
|
+
return _b64.b64encode(crudo.getvalue()).decode()
|
|
730
|
+
|
|
731
|
+
bueno = zipea([("mi-skill/SKILL.md", "---\nname: mi-skill\n---\nhola"),
|
|
732
|
+
("mi-skill/extra.md", "más")])
|
|
733
|
+
st, cuerpo = pide(
|
|
734
|
+
puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "zip": bueno}
|
|
735
|
+
)
|
|
736
|
+
st2, cuerpo2 = pide(puerto, "/api/estado")
|
|
737
|
+
skills_notas = json.loads(cuerpo2)["skills"].get("notas", {}).get("skills", [])
|
|
738
|
+
afirma(
|
|
739
|
+
"· happy: an uploaded skill lands in the agent's home and is discovered live",
|
|
740
|
+
st == 200 and any(s["name"] == "mi-skill" for s in skills_notas),
|
|
741
|
+
str(skills_notas),
|
|
742
|
+
)
|
|
743
|
+
st, _ = pide(
|
|
744
|
+
puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "zip": bueno}
|
|
745
|
+
)
|
|
746
|
+
comprueba("· non-happy: installing over an existing skill is refused", st, 409)
|
|
747
|
+
st, _ = pide(
|
|
748
|
+
puerto,
|
|
749
|
+
"/api/skill",
|
|
750
|
+
metodo="POST",
|
|
751
|
+
cuerpo={"agent": "notas", "zip": zipea([("../fuera/SKILL.md", "x")])},
|
|
752
|
+
)
|
|
753
|
+
comprueba("· non-happy: a zip that tries to escape its folder is refused", st, 400)
|
|
754
|
+
st, _ = pide(
|
|
755
|
+
puerto,
|
|
756
|
+
"/api/skill",
|
|
757
|
+
metodo="POST",
|
|
758
|
+
cuerpo={
|
|
759
|
+
"agent": "notas",
|
|
760
|
+
"zip": zipea([("liada/SKILL.md", "x")], enlace="liada/mala"),
|
|
761
|
+
},
|
|
762
|
+
)
|
|
763
|
+
comprueba("· non-happy: symlinks inside a skill are refused", st, 400)
|
|
764
|
+
st, _ = pide(
|
|
765
|
+
puerto,
|
|
766
|
+
"/api/skill",
|
|
767
|
+
metodo="POST",
|
|
768
|
+
cuerpo={"agent": "notas", "name": "sin-manifiesto", "zip": zipea([("leeme.md", "x")])},
|
|
769
|
+
)
|
|
770
|
+
comprueba("· non-happy: a skill is a folder with a SKILL.md, or nothing", st, 400)
|
|
771
|
+
st, _ = pide(
|
|
772
|
+
puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "remove": "mi-skill"}
|
|
773
|
+
)
|
|
774
|
+
st2, cuerpo2 = pide(puerto, "/api/estado")
|
|
775
|
+
skills_notas = json.loads(cuerpo2)["skills"].get("notas", {}).get("skills", [])
|
|
776
|
+
afirma(
|
|
777
|
+
"· happy: removing an installed skill empties exactly its folder",
|
|
778
|
+
st == 200 and not any(s["name"] == "mi-skill" for s in skills_notas),
|
|
779
|
+
str(skills_notas),
|
|
780
|
+
)
|
|
781
|
+
st, _ = pide(
|
|
782
|
+
puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "remove": "fantasma"}
|
|
783
|
+
)
|
|
784
|
+
comprueba("· non-happy: removing a skill that is not there is a 404", st, 404)
|
|
785
|
+
|
|
786
|
+
# The 14 MB cap bounds the upload; only the extraction budget bounds
|
|
787
|
+
# what a hostile deflate ratio inflates it to on disk. 80 MB of zeros
|
|
788
|
+
# deflates to under 100 KB — small on the wire, huge when it lands.
|
|
789
|
+
crudo_bomba = _io.BytesIO()
|
|
790
|
+
with _zip.ZipFile(crudo_bomba, "w", _zip.ZIP_DEFLATED) as z:
|
|
791
|
+
z.writestr("boom/SKILL.md", "---\nname: boom\n---\n")
|
|
792
|
+
z.writestr("boom/ceros.bin", b"\0" * (80 * 1024 * 1024))
|
|
793
|
+
st, cuerpo = pide(
|
|
794
|
+
puerto,
|
|
795
|
+
"/api/skill",
|
|
796
|
+
metodo="POST",
|
|
797
|
+
cuerpo={
|
|
798
|
+
"agent": "notas",
|
|
799
|
+
"zip": _b64.b64encode(crudo_bomba.getvalue()).decode(),
|
|
800
|
+
},
|
|
801
|
+
)
|
|
802
|
+
afirma(
|
|
803
|
+
"· non-happy: a zip bomb is refused by its decompressed size, not its upload size",
|
|
804
|
+
st == 400 and "inflates too large" in json.loads(cuerpo).get("error", ""),
|
|
805
|
+
cuerpo.decode(errors="replace"),
|
|
806
|
+
)
|
|
807
|
+
# A zip whose entries collide (a file, then a directory of the same
|
|
808
|
+
# name) once killed the handler mid-write and its debris 409'd every
|
|
809
|
+
# retry until deleted by hand.
|
|
810
|
+
st, _ = pide(
|
|
811
|
+
puerto,
|
|
812
|
+
"/api/skill",
|
|
813
|
+
metodo="POST",
|
|
814
|
+
cuerpo={
|
|
815
|
+
"agent": "notas",
|
|
816
|
+
"zip": zipea([("s/SKILL.md", "---\nname: s\n---\n"), ("s/f", "x"), ("s/f/g", "y")]),
|
|
817
|
+
},
|
|
818
|
+
)
|
|
819
|
+
st2, _ = pide(
|
|
820
|
+
puerto,
|
|
821
|
+
"/api/skill",
|
|
822
|
+
metodo="POST",
|
|
823
|
+
cuerpo={"agent": "notas", "zip": zipea([("s/SKILL.md", "---\nname: s\n---\n")])},
|
|
824
|
+
)
|
|
825
|
+
afirma(
|
|
826
|
+
"· non-happy: a colliding zip is a 400 that cleans up — the retry installs fine",
|
|
827
|
+
st == 400 and st2 == 200,
|
|
828
|
+
f"{st}/{st2}",
|
|
829
|
+
)
|
|
830
|
+
pide(puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "remove": "s"})
|
|
831
|
+
# Removal is keyed on the FOLDER, and the sheet is told which skills the
|
|
832
|
+
# Hall itself could remove: a SKILL.md display name is free to differ,
|
|
833
|
+
# and a repo's committed skill is the repo's property — no × for it.
|
|
834
|
+
pide(
|
|
835
|
+
puerto,
|
|
836
|
+
"/api/skill",
|
|
837
|
+
metodo="POST",
|
|
838
|
+
cuerpo={
|
|
839
|
+
"agent": "notas",
|
|
840
|
+
"zip": zipea([("deploy-tools/SKILL.md", "---\nname: deploy\n---\n")]),
|
|
841
|
+
},
|
|
842
|
+
)
|
|
843
|
+
with open(os.path.join(taller, "SKILL.md"), "w", encoding="utf-8") as f:
|
|
844
|
+
f.write("---\nname: del-repo\n---\n")
|
|
845
|
+
st, cuerpo = pide(puerto, "/api/estado")
|
|
846
|
+
skills_notas = json.loads(cuerpo)["skills"].get("notas", {}).get("skills", [])
|
|
847
|
+
subida = next((s for s in skills_notas if s["name"] == "deploy"), None)
|
|
848
|
+
del_repo = next((s for s in skills_notas if s["name"] == "del-repo"), None)
|
|
849
|
+
afirma(
|
|
850
|
+
"· happy: an installed skill is marked removable, keyed on its folder",
|
|
851
|
+
subida is not None
|
|
852
|
+
and subida.get("removable") is True
|
|
853
|
+
and subida.get("dir") == "deploy-tools",
|
|
854
|
+
str(subida),
|
|
855
|
+
)
|
|
856
|
+
afirma(
|
|
857
|
+
"· a skill committed elsewhere in the home is discovered but never removable",
|
|
858
|
+
del_repo is not None and not del_repo.get("removable"),
|
|
859
|
+
str(del_repo),
|
|
860
|
+
)
|
|
861
|
+
st, _ = pide(
|
|
862
|
+
puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "remove": "deploy"}
|
|
863
|
+
)
|
|
864
|
+
comprueba("· non-happy: the display name is not the folder — a 404, never a guess", st, 404)
|
|
865
|
+
st, _ = pide(
|
|
866
|
+
puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "remove": "deploy-tools"}
|
|
867
|
+
)
|
|
868
|
+
comprueba("· happy: removal by folder name removes exactly that folder", st, 200)
|
|
869
|
+
os.unlink(os.path.join(taller, "SKILL.md"))
|
|
870
|
+
# A repo can commit `.claude/skills` as a symlink to anywhere this user
|
|
871
|
+
# can write — the global skills folder included. Both the install and
|
|
872
|
+
# the rmtree once followed it out of the agent's home.
|
|
873
|
+
raiz_skills = os.path.join(taller, ".claude", "skills")
|
|
874
|
+
shutil.rmtree(raiz_skills, ignore_errors=True)
|
|
875
|
+
fuera_de_casa = tempfile.mkdtemp()
|
|
876
|
+
os.symlink(fuera_de_casa, raiz_skills)
|
|
877
|
+
st, _ = pide(
|
|
878
|
+
puerto,
|
|
879
|
+
"/api/skill",
|
|
880
|
+
metodo="POST",
|
|
881
|
+
cuerpo={"agent": "notas", "zip": zipea([("fuga/SKILL.md", "---\nname: fuga\n---\n")])},
|
|
882
|
+
)
|
|
883
|
+
st2, _ = pide(
|
|
884
|
+
puerto, "/api/skill", metodo="POST", cuerpo={"agent": "notas", "remove": "fuga"}
|
|
885
|
+
)
|
|
886
|
+
afirma(
|
|
887
|
+
"· non-happy: a symlinked .claude/skills is refused for install and removal alike",
|
|
888
|
+
st == 409 and st2 == 409 and not os.listdir(fuera_de_casa),
|
|
889
|
+
f"{st}/{st2}/{os.listdir(fuera_de_casa)}",
|
|
890
|
+
)
|
|
891
|
+
os.unlink(raiz_skills)
|
|
892
|
+
shutil.rmtree(fuera_de_casa, ignore_errors=True)
|
|
893
|
+
|
|
894
|
+
# ── the engine's traffic light and its test button ───────────────────
|
|
895
|
+
print(" the engine's traffic light, derived and never guessed")
|
|
896
|
+
# A fake binary makes the light deterministic on any machine, CI included.
|
|
897
|
+
cajon = tempfile.mkdtemp()
|
|
898
|
+
falso = os.path.join(cajon, "kimi")
|
|
899
|
+
with open(falso, "w") as f:
|
|
900
|
+
f.write('#!/bin/sh\necho "kimi 9.9.9"\n')
|
|
901
|
+
os.chmod(falso, 0o755)
|
|
902
|
+
ruta_previa = os.environ["PATH"]
|
|
903
|
+
os.environ["PATH"] = cajon + os.pathsep + ruta_previa
|
|
904
|
+
try:
|
|
905
|
+
pide(puerto, "/api/agente", metodo="POST", cuerpo={"agent": "notas", "runtime": "kimi"})
|
|
906
|
+
st, cuerpo = pide(puerto, "/api/estado")
|
|
907
|
+
hoja = next(x for x in json.loads(cuerpo)["agents"] if x["name"] == "notas")
|
|
908
|
+
afirma(
|
|
909
|
+
"· yellow: the binary exists and the agent's window is not running",
|
|
910
|
+
st == 200
|
|
911
|
+
and hoja["cli"]["binary"] == "kimi"
|
|
912
|
+
and hoja["cli"]["installed"] is True
|
|
913
|
+
and hoja["cli"]["connected"] is False,
|
|
914
|
+
str(hoja["cli"]),
|
|
915
|
+
)
|
|
916
|
+
st, cuerpo = pide(puerto, "/api/motor", metodo="POST", cuerpo={"agent": "notas"})
|
|
917
|
+
prueba = json.loads(cuerpo)
|
|
918
|
+
afirma(
|
|
919
|
+
"· happy: the test button runs the real binary and reports its version",
|
|
920
|
+
st == 200 and prueba["ok"] is True and "9.9.9" in prueba.get("version", ""),
|
|
921
|
+
str(prueba),
|
|
922
|
+
)
|
|
923
|
+
os.environ["PATH"] = cajon # only the fake folder: kimi gone nowhere, others missing
|
|
924
|
+
os.remove(falso)
|
|
925
|
+
st, cuerpo = pide(puerto, "/api/motor", metodo="POST", cuerpo={"agent": "notas"})
|
|
926
|
+
prueba = json.loads(cuerpo)
|
|
927
|
+
afirma(
|
|
928
|
+
"· red: a missing binary is an honest answer, not an HTTP error",
|
|
929
|
+
st == 200 and prueba["ok"] is False and "not on this machine" in prueba["detail"],
|
|
930
|
+
str(prueba),
|
|
931
|
+
)
|
|
932
|
+
st, _ = pide(puerto, "/api/motor", metodo="POST", cuerpo={"agent": "nadie"})
|
|
933
|
+
comprueba("· non-happy: testing an unknown agent is a 404", st, 404)
|
|
934
|
+
finally:
|
|
935
|
+
os.environ["PATH"] = ruta_previa
|
|
936
|
+
shutil.rmtree(cajon, ignore_errors=True)
|
|
937
|
+
|
|
938
|
+
# ── regressions pinned by review ─────────────────────────────────────
|
|
939
|
+
print(" review regressions, pinned")
|
|
940
|
+
# tmux prefix-matches session names: bare `-t home` also matches
|
|
941
|
+
# `home-2`, lighting the green dot from a different city's windows.
|
|
942
|
+
fuente = open(os.path.join(AQUI, "serve.py"), encoding="utf-8").read()
|
|
943
|
+
afirma(
|
|
944
|
+
"· ventanas_vivas asks tmux for an exact session match",
|
|
945
|
+
'"=" + cities.sesion(owner, datos)' in fuente,
|
|
946
|
+
)
|
|
947
|
+
# An 80-char slug is a legal window name; a 64-char cap on the resolver
|
|
948
|
+
# made every sheet action 404 while the sheet itself rendered fine.
|
|
949
|
+
largo = "l" * 70
|
|
950
|
+
with open(os.path.join(hallDatos, "halltest.md"), "w", encoding="utf-8") as f:
|
|
951
|
+
f.write(
|
|
952
|
+
"---\n"
|
|
953
|
+
"user: halltest\nname: Hall Test\nrole: cpto\nagent: halltest/seat\n"
|
|
954
|
+
f"agents: [notas, {largo}]\nkind.notas: knowledge\n"
|
|
955
|
+
"goals_defined: true\n---\n"
|
|
956
|
+
)
|
|
957
|
+
st, cuerpo = pide(
|
|
958
|
+
puerto, "/api/agente", metodo="POST", cuerpo={"agent": largo, "model": "haiku"}
|
|
959
|
+
)
|
|
960
|
+
afirma(
|
|
961
|
+
"· a 65-80 char slug can use its sheet's actions, not only render it",
|
|
962
|
+
st == 200 and json.loads(cuerpo)["agent"]["slug"] == largo,
|
|
963
|
+
cuerpo.decode(errors="replace")[:200],
|
|
964
|
+
)
|
|
965
|
+
# Two names that slug to one identity: the card tools raise loudly;
|
|
966
|
+
# the Hall and read-only discovery must degrade, never traceback.
|
|
967
|
+
import capabilities as _cap
|
|
968
|
+
|
|
969
|
+
with open(os.path.join(hallDatos, "halltest.md"), "w", encoding="utf-8") as f:
|
|
970
|
+
f.write(
|
|
971
|
+
"---\nuser: halltest\nname: Hall Test\nrole: cpto\nagent: halltest/seat\n"
|
|
972
|
+
"agents: [store-service, store_service]\ngoals_defined: true\n---\n"
|
|
973
|
+
)
|
|
974
|
+
comprueba(
|
|
975
|
+
"· skills discovery over a slug-colliding card lists nothing, crashes nothing",
|
|
976
|
+
_cap.descubre_ciudad(hallDatos),
|
|
977
|
+
{},
|
|
978
|
+
)
|
|
979
|
+
st, cuerpo = pide(puerto, "/api/estado")
|
|
980
|
+
afirma(
|
|
981
|
+
"· /api/estado still answers over a slug-colliding card",
|
|
982
|
+
st == 200 and json.loads(cuerpo).get("agents") == [],
|
|
983
|
+
cuerpo.decode(errors="replace")[:200],
|
|
984
|
+
)
|
|
985
|
+
# The Hall binds loopback without the stdlib's socket.getfqdn() call:
|
|
986
|
+
# a reverse DNS lookup for a cosmetic name, which on an unanswered
|
|
987
|
+
# resolver stalls the server's start for tens of seconds.
|
|
988
|
+
import socket as _socket
|
|
989
|
+
import threading as _th
|
|
990
|
+
import time as _time
|
|
991
|
+
|
|
992
|
+
_fqdn = _socket.getfqdn
|
|
993
|
+
_socket.getfqdn = lambda *a: _time.sleep(60) or "nunca"
|
|
994
|
+
otro = {}
|
|
995
|
+
try:
|
|
996
|
+
atado = _th.Event()
|
|
997
|
+
|
|
998
|
+
def _ata():
|
|
999
|
+
otro["s"] = serve.Servidor(("127.0.0.1", 0), serve.Manejador)
|
|
1000
|
+
atado.set()
|
|
1001
|
+
|
|
1002
|
+
_th.Thread(target=_ata, daemon=True).start()
|
|
1003
|
+
afirma("· the Hall binds without waiting on reverse DNS", atado.wait(timeout=5))
|
|
1004
|
+
finally:
|
|
1005
|
+
_socket.getfqdn = _fqdn
|
|
1006
|
+
if otro.get("s"):
|
|
1007
|
+
otro["s"].server_close()
|
|
1008
|
+
|
|
1009
|
+
# A code agent's growth reads real local git history — merge commits
|
|
1010
|
+
# as floors — instead of answering zero for lack of a counter.
|
|
1011
|
+
import subprocess as _sp
|
|
1012
|
+
import types as _types
|
|
1013
|
+
|
|
1014
|
+
repo = tempfile.mkdtemp()
|
|
1015
|
+
for orden in (
|
|
1016
|
+
["git", "init", "-q"],
|
|
1017
|
+
["git", "-c", "user.email=t@t", "-c", "user.name=t",
|
|
1018
|
+
"commit", "--allow-empty", "-q", "-m", "uno"],
|
|
1019
|
+
["git", "-c", "user.email=t@t", "-c", "user.name=t",
|
|
1020
|
+
"commit", "--allow-empty", "-q", "-m", "dos"],
|
|
1021
|
+
):
|
|
1022
|
+
_sp.run(orden, cwd=repo, capture_output=True)
|
|
1023
|
+
obrero = _types.SimpleNamespace(
|
|
1024
|
+
nombre="obrero", slug="obrero", legacy=False, workspace=repo, mounts=[]
|
|
1025
|
+
)
|
|
1026
|
+
prs, commits, act = serve.contador_git_local(obrero, hallDatos)
|
|
1027
|
+
afirma(
|
|
1028
|
+
"· the sheet's git counter reads real local history, no network",
|
|
1029
|
+
prs == 0 and commits == 2 and act == 2,
|
|
1030
|
+
f"{prs}/{commits}/{act}",
|
|
1031
|
+
)
|
|
1032
|
+
shutil.rmtree(repo, ignore_errors=True)
|
|
1033
|
+
|
|
1034
|
+
# /api/sesion is NOT exercised live on purpose: it spawns tmux windows
|
|
1035
|
+
# with real agents in them. Its refusal without the token is covered by
|
|
1036
|
+
# the blanket auth checks above, which is the part that must hold.
|
|
1037
|
+
finally:
|
|
1038
|
+
for k in (
|
|
1039
|
+
"AGENTS_CITY_DATA",
|
|
1040
|
+
"AGENTS_CITY_HOME",
|
|
1041
|
+
"AGENTS_CITY_USER",
|
|
1042
|
+
"CITY_SEARCH_IN",
|
|
1043
|
+
"XDG_CACHE_HOME",
|
|
1044
|
+
):
|
|
1045
|
+
os.environ.pop(k, None)
|
|
1046
|
+
shutil.rmtree(hallDatos, ignore_errors=True)
|
|
1047
|
+
shutil.rmtree(appDatos, ignore_errors=True)
|
|
1048
|
+
shutil.rmtree(arbitraryDatos, ignore_errors=True)
|
|
1049
|
+
servidor.shutdown()
|
|
1050
|
+
servidor.server_close()
|
|
1051
|
+
shutil.rmtree(destino, ignore_errors=True)
|
|
1052
|
+
|
|
1053
|
+
return resumen("serve")
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
if __name__ == "__main__":
|
|
1057
|
+
sys.exit(main())
|