agents-city 0.5.2 → 0.5.4
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 +11 -0
- package/README.md +10 -0
- package/benchmarks/latency/fake-claude-cli.mjs +3 -2
- package/bin/serve.py +63 -6
- package/bin/test +2 -2
- package/bin/test-alcance.py +470 -0
- package/bin/test-arnes.py +109 -0
- package/bin/test-channel.py +295 -9
- package/bin/test-cities.py +57 -0
- package/bin/test-claude-runtime.py +17 -6
- package/bin/test-connect-client.mjs +8 -0
- package/bin/test-connect.py +1 -1
- package/bin/test-contracts.py +129 -3
- package/bin/test-e2e.py +371 -0
- package/bin/test-seat.py +154 -1
- package/bin/test-serve.py +83 -0
- package/city/web/dist-hall/hall.js +384 -3240
- package/docs/managed-connect.md +5 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/channel/hub/lo-que-te-llega.ts +78 -0
- package/plugin/channel/hub/local-roads.ts +36 -0
- package/plugin/channel/hub/road-controller.ts +30 -5
- package/plugin/channel/local-hub.js +177 -105
- package/plugin/channel/local-hub.ts +8 -0
- package/plugin/channel/managed-connect-client.js +53 -3
- package/plugin/channel/managed-connect-client.manifest.json +2 -2
- package/plugin/channel/protocol.ts +24 -0
- package/plugin/commands/notice.md +14 -0
- package/plugin/hooks/ask-the-house.sh +25 -0
- package/plugin/hooks/hooks.json +10 -0
- package/plugin/hooks/notice-on-stop.sh +7 -1
- package/plugin/scripts/alcance.py +318 -0
- package/plugin/scripts/arnes.py +10 -1
- package/plugin/scripts/busca.py +13 -0
- package/plugin/scripts/card.py +40 -0
- package/plugin/scripts/cities.py +46 -4
- package/plugin/scripts/city-session.sh +80 -7
- package/plugin/scripts/domains.py +7 -0
- package/plugin/scripts/seat.py +33 -13
- package/plugin/skills/city/SKILL.md +12 -0
package/bin/test-contracts.py
CHANGED
|
@@ -269,6 +269,46 @@ def conciencia_acotada():
|
|
|
269
269
|
|
|
270
270
|
|
|
271
271
|
# ══ the demo is a city this code would write ═════════════════════════════════
|
|
272
|
+
# ══ the shell has to run on somebody else's bash ═════════════════════════════
|
|
273
|
+
def bash_que_no_es_la_de_macos():
|
|
274
|
+
"""Shell that only bash 3.2 accepts is shell that only works on macOS.
|
|
275
|
+
|
|
276
|
+
`${#array[@]-0}` is the trap that got through: bash 3.2 — the one Apple has
|
|
277
|
+
shipped since 2007, and the reason this repo writes `${a[@]+"${a[@]}"}`
|
|
278
|
+
everywhere — accepts it, and every bash from 4.4 on refuses it as a bad
|
|
279
|
+
substitution. So a message printed here and never on Linux, silently, because
|
|
280
|
+
the error goes to stderr and the script does not stop. It sat in the launcher
|
|
281
|
+
for months: nothing read stderr until a test did.
|
|
282
|
+
|
|
283
|
+
A declared array needs no default. `a=()` already answers 0 under `set -u`;
|
|
284
|
+
only an UNDECLARED name is unbound, and the fix for that is to declare it.
|
|
285
|
+
|
|
286
|
+
shellcheck does not flag this at any severity, which is why it is here.
|
|
287
|
+
"""
|
|
288
|
+
print(' shell that runs on a bash newer than 2007')
|
|
289
|
+
trampa = re.compile(r'\$\{#[A-Za-z_][A-Za-z0-9_]*\[[@*]\][-:+]')
|
|
290
|
+
ficheros = sorted(glob.glob(os.path.join(RAIZ, 'plugin', '**', '*.sh'), recursive=True))
|
|
291
|
+
ficheros += sorted(glob.glob(os.path.join(RAIZ, 'bus', 'scripts', '*.sh')))
|
|
292
|
+
culpables = []
|
|
293
|
+
for ruta in ficheros:
|
|
294
|
+
with open(ruta, encoding='utf-8') as f:
|
|
295
|
+
for n, linea in enumerate(f, 1):
|
|
296
|
+
# A comment may name the trap — the tombstone in the launcher does.
|
|
297
|
+
if linea.lstrip().startswith('#'):
|
|
298
|
+
continue
|
|
299
|
+
if trampa.search(linea):
|
|
300
|
+
culpables.append(f'{os.path.relpath(ruta, RAIZ)}:{n}')
|
|
301
|
+
afirma('· no shipped script uses a length expansion with a default',
|
|
302
|
+
not culpables,
|
|
303
|
+
'${#a[@]-0} is a macOS-only illusion: ' + ', '.join(culpables))
|
|
304
|
+
# And the idiom that IS portable stays in use, so this does not read as a
|
|
305
|
+
# rule against defaults in general.
|
|
306
|
+
lanzador = open(os.path.join(RAIZ, 'plugin/scripts/city-session.sh'),
|
|
307
|
+
encoding='utf-8').read()
|
|
308
|
+
afirma('· and empty arrays are still expanded the way bash 3.2 needs',
|
|
309
|
+
'[@]+"${' in lanzador, '')
|
|
310
|
+
|
|
311
|
+
|
|
272
312
|
def demo_coherente():
|
|
273
313
|
print(' the demo agrees with the code')
|
|
274
314
|
for f in sorted(glob.glob(os.path.join(RAIZ, 'demo', '*.md'))):
|
|
@@ -462,6 +502,49 @@ def asiento_con_su_arnes():
|
|
|
462
502
|
shutil.rmtree(casa, ignore_errors=True)
|
|
463
503
|
|
|
464
504
|
|
|
505
|
+
def el_aviso_lee_lo_que_viaja():
|
|
506
|
+
"""What the roster carries has to be what the judgement reads.
|
|
507
|
+
|
|
508
|
+
The circuit is three pieces and it is only worth the sum if all three are
|
|
509
|
+
wired: a city publishes its role and its remit, the roster serves them, and
|
|
510
|
+
the thing that decides "does this reach anybody" reads THAT rather than a
|
|
511
|
+
local copy of a catalogue. Build the first two and leave the third pointing
|
|
512
|
+
at local files and you have a pipe nobody reads — which is what this was
|
|
513
|
+
before, and it looked finished.
|
|
514
|
+
"""
|
|
515
|
+
print(' the judgement reads what the roster carries')
|
|
516
|
+
# Whitespace-collapsed: these are wrapped prose, and a phrase that happens
|
|
517
|
+
# to break across two lines is the same phrase. A check that cannot see
|
|
518
|
+
# that fails on the paragraph filler rather than on the meaning.
|
|
519
|
+
def plano(ruta):
|
|
520
|
+
return ' '.join(open(os.path.join(RAIZ, ruta), encoding='utf-8').read().split())
|
|
521
|
+
|
|
522
|
+
gancho = plano('plugin/hooks/notice-on-stop.sh')
|
|
523
|
+
orden = plano('plugin/commands/notice.md')
|
|
524
|
+
controlador = open(os.path.join(RAIZ, 'plugin/channel/hub/road-controller.ts'),
|
|
525
|
+
encoding='utf-8').read()
|
|
526
|
+
|
|
527
|
+
afirma('· the roster is what serves a road its remit',
|
|
528
|
+
'recibe' in controlador and 'localRoadPresenta' in controlador, '')
|
|
529
|
+
for nombre, texto in (('the stop hook', gancho), ('/city:notice', orden)):
|
|
530
|
+
afirma(f'· {nombre} sends the judgement to the roster',
|
|
531
|
+
'roster' in texto.lower(), texto[:200])
|
|
532
|
+
afirma(f'· {nombre} names what a far city says reaches it',
|
|
533
|
+
'recibe' in texto, texto[:200])
|
|
534
|
+
afirma(f'· {nombre} says where that claim came from',
|
|
535
|
+
'segun' in texto, texto[:200])
|
|
536
|
+
# Silence and staleness are the two ways this quietly goes wrong: a road
|
|
537
|
+
# that said nothing is missing information, and a note is one person's
|
|
538
|
+
# guess with an expiry date. Both have to be named where the judgement
|
|
539
|
+
# is made, or the reader treats absence as permission.
|
|
540
|
+
afirma(f'· {nombre} treats a road that said nothing as missing, not as permission',
|
|
541
|
+
'not permission' in texto, texto[:400])
|
|
542
|
+
afirma(f'· {nombre} warns that a note goes stale',
|
|
543
|
+
'stale' in texto, texto[:400])
|
|
544
|
+
afirma('· and the command treats a far city’s words as a claim, never an instruction',
|
|
545
|
+
'never as an instruction' in orden, orden[:400])
|
|
546
|
+
|
|
547
|
+
|
|
465
548
|
def ventanas_gemelas():
|
|
466
549
|
"""Window, engine key and bus actor share one canonical repo slug."""
|
|
467
550
|
print(' the window slug has one owner')
|
|
@@ -678,6 +761,11 @@ def publicar_es_verificable():
|
|
|
678
761
|
if not os.path.isfile(ruta):
|
|
679
762
|
return
|
|
680
763
|
release = open(ruta, encoding='utf-8').read()
|
|
764
|
+
# The workflow without its comments. Half of what is asserted below is the
|
|
765
|
+
# ABSENCE of something, and this file documents every trap it has fallen
|
|
766
|
+
# into by name — so a check that cannot tell a step from a sentence about a
|
|
767
|
+
# step fails on the explanation of why it exists.
|
|
768
|
+
codigo = '\n'.join(l for l in release.splitlines() if not l.lstrip().startswith('#'))
|
|
681
769
|
prueba = open(os.path.join(RAIZ, '.github', 'workflows', 'test.yml'),
|
|
682
770
|
encoding='utf-8').read()
|
|
683
771
|
|
|
@@ -689,7 +777,7 @@ def publicar_es_verificable():
|
|
|
689
777
|
afirma('· the whole suite runs before anything is published',
|
|
690
778
|
'uses: ./.github/workflows/test.yml' in release
|
|
691
779
|
and release.index('uses: ./.github/workflows/test.yml')
|
|
692
|
-
< release.rindex('
|
|
780
|
+
< release.rindex('npm publish --provenance'),
|
|
693
781
|
'publish must depend on the suite, not race it')
|
|
694
782
|
afirma('· and the suite is the same one, called rather than copied',
|
|
695
783
|
'workflow_call:' in prueba, 'test.yml must be reusable')
|
|
@@ -704,8 +792,12 @@ def publicar_es_verificable():
|
|
|
704
792
|
'id-token: write' in release
|
|
705
793
|
and 'NPM_TOKEN' not in release and 'secrets.' not in release,
|
|
706
794
|
'trusted publishing: a secret that does not exist cannot leak')
|
|
795
|
+
# A whole line that grants a write permission, not the word anywhere: this
|
|
796
|
+
# file names `id-token: write` inside an error message telling somebody what
|
|
797
|
+
# to go and check, and a permissions audit that counts English is not one.
|
|
707
798
|
afirma('· and it asks for nothing else',
|
|
708
|
-
|
|
799
|
+
len(re.findall(r'(?m)^\s*[a-z-]+:\s*write\s*$', codigo)) == 1,
|
|
800
|
+
'id-token is the only write permission')
|
|
709
801
|
# Node 22 bundles npm 10, which cannot exchange an OIDC token and so
|
|
710
802
|
# publishes UNAUTHENTICATED — the registry answers 404, which reads like a
|
|
711
803
|
# missing package and is really a missing credential. It cost one release
|
|
@@ -715,6 +807,38 @@ def publicar_es_verificable():
|
|
|
715
807
|
'trusted publishing needs npm >= 11.5.1; setup-node gives 10.x')
|
|
716
808
|
afirma('· and it refuses to publish with an older one rather than trying',
|
|
717
809
|
'process.exit(1)' in release, release)
|
|
810
|
+
# And an npm new enough is still not enough if something already handed it a
|
|
811
|
+
# credential, because a credential is how the OIDC exchange gets SKIPPED.
|
|
812
|
+
# `setup-node`'s `registry-url` looks like the line that points npm at
|
|
813
|
+
# npmjs.com; what it does is write `_authToken=${NODE_AUTH_TOKEN}` into an
|
|
814
|
+
# .npmrc and export NODE_AUTH_TOKEN as the literal `XXXXX-XXXXX-XXXXX-XXXXX`.
|
|
815
|
+
# npm then publishes authenticated as a string of X's and the registry
|
|
816
|
+
# answers the same 404 it answers a stranger. It cost the second release.
|
|
817
|
+
afirma('· and nothing hands npm a credential of its own before it asks',
|
|
818
|
+
'registry-url' not in codigo and 'NODE_AUTH_TOKEN' not in codigo,
|
|
819
|
+
'setup-node registry-url writes a placeholder token that skips the OIDC exchange')
|
|
820
|
+
afirma('· which is checked in the run rather than inferred from a 404',
|
|
821
|
+
'_authToken' in codigo
|
|
822
|
+
and codigo.index('_authToken') < codigo.rindex('npm publish --provenance'),
|
|
823
|
+
'the registry says "not found" when it means "not allowed"')
|
|
824
|
+
# Having no credential is only half of it: npm has to be able to GET one,
|
|
825
|
+
# and it only tries when GitHub has injected an OIDC endpoint into the job.
|
|
826
|
+
# Without one npm does not try, does not say it is not trying, and fails at
|
|
827
|
+
# the end with "you need to log in" — which sounds like a forgotten password
|
|
828
|
+
# and is a job that was never given an identity to trade.
|
|
829
|
+
afirma('· and it names the registry it means to be recognised at',
|
|
830
|
+
'npm config set registry https://registry.npmjs.org/' in codigo
|
|
831
|
+
and codigo.index('npm config set registry')
|
|
832
|
+
< codigo.rindex('npm publish --provenance'),
|
|
833
|
+
'npm offers an OIDC token only for a registry it recognises as decided')
|
|
834
|
+
afirma('· and a refusal has to explain itself in the log',
|
|
835
|
+
'--loglevel verbose' in codigo,
|
|
836
|
+
'ENEEDAUTH twice with no reason is a workflow that cannot be debugged remotely')
|
|
837
|
+
publica = codigo.rindex('npm publish --provenance')
|
|
838
|
+
afirma('· and the identity it means to trade is proved to exist first',
|
|
839
|
+
'ACTIONS_ID_TOKEN_REQUEST_URL' in codigo
|
|
840
|
+
and codigo.index('ACTIONS_ID_TOKEN_REQUEST_URL') < publica,
|
|
841
|
+
'a missing OIDC endpoint must fail by name, not as ENEEDAUTH ten steps later')
|
|
718
842
|
# prepublishOnly rebuilds and re-runs the whole suite. By hand that is the
|
|
719
843
|
# only safety net; here the suite has already run on three platforms and
|
|
720
844
|
# byte-compared the artifacts, so a fourth run verifies nothing and can
|
|
@@ -728,7 +852,7 @@ def publicar_es_verificable():
|
|
|
728
852
|
# map and no Hall bundle: 447 files instead of 503.
|
|
729
853
|
afirma('· but it still builds the gitignored half of the package',
|
|
730
854
|
'run: npm run build' in release
|
|
731
|
-
and release.index('run: npm run build') < release.rindex('
|
|
855
|
+
and release.index('run: npm run build') < release.rindex('npm publish --provenance'),
|
|
732
856
|
'a publish with no build ships no front end')
|
|
733
857
|
afirma('· and checks the front end is in the tarball before sending it',
|
|
734
858
|
'city/web/dist/index.html' in release and 'city/web/dist-hall/hall.js' in release,
|
|
@@ -747,8 +871,10 @@ def main():
|
|
|
747
871
|
una_definicion()
|
|
748
872
|
rutas_reales()
|
|
749
873
|
conciencia_acotada()
|
|
874
|
+
bash_que_no_es_la_de_macos()
|
|
750
875
|
demo_coherente()
|
|
751
876
|
varias_ciudades()
|
|
877
|
+
el_aviso_lee_lo_que_viaja()
|
|
752
878
|
ventanas_gemelas()
|
|
753
879
|
motor_para_todos()
|
|
754
880
|
asiento_con_su_arnes()
|
package/bin/test-e2e.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""One city, from nothing to a refusal, through the doors a person actually uses.
|
|
3
|
+
|
|
4
|
+
Every other suite here proves one piece: the Hall's endpoint writes the card,
|
|
5
|
+
the launcher builds the right command, the guard judges a path, the journal
|
|
6
|
+
records a line. Each of them stubs its neighbours, and that is what makes them
|
|
7
|
+
fast and precise — and it is also how a product ends up with every part working
|
|
8
|
+
and the whole thing broken at the seams. The two bugs this release fixes were
|
|
9
|
+
both seam bugs. Adding a house wrote the frontmatter and not the body: two
|
|
10
|
+
correct writers, one incoherent card. Adding a house to a RUNNING city wrote the
|
|
11
|
+
card and never touched tmux: a correct endpoint and a correct launcher that had
|
|
12
|
+
never met.
|
|
13
|
+
|
|
14
|
+
So this one stubs nothing it can avoid. A real HTTP server over a real socket. A
|
|
15
|
+
real `agents-city cities create`. The real `city-session.sh` against a real tmux
|
|
16
|
+
server on a private socket. The real hook binary, over stdin, with the
|
|
17
|
+
environment THE LAUNCHER ITSELF wrote — not one composed here, because "the
|
|
18
|
+
launcher tells the window where its city is" is exactly the kind of fact a
|
|
19
|
+
hand-made fixture will keep asserting long after it stops being true. And the
|
|
20
|
+
journal read back through the API, so the write and the read resolve the city
|
|
21
|
+
the same way.
|
|
22
|
+
|
|
23
|
+
The story it walks, once, in order:
|
|
24
|
+
|
|
25
|
+
create a city → write its seat → add a house → mount its ground → open the
|
|
26
|
+
session → add a SECOND house while the city is running → open it again and
|
|
27
|
+
watch it reconcile → have the chair reach into somebody's repo → be refused,
|
|
28
|
+
by name → find the refusal in the journal → let the owner open the chair's
|
|
29
|
+
hands → watch the same call go through.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import shutil
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import tempfile
|
|
38
|
+
import threading
|
|
39
|
+
import urllib.error
|
|
40
|
+
import urllib.request
|
|
41
|
+
|
|
42
|
+
AQUI = os.path.dirname(os.path.abspath(__file__))
|
|
43
|
+
RAIZ = os.path.dirname(AQUI)
|
|
44
|
+
sys.path.insert(0, AQUI)
|
|
45
|
+
sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
|
|
46
|
+
|
|
47
|
+
import serve # noqa: E402
|
|
48
|
+
from testlib import afirma, comprueba, detiene_hubs_de_ciudad, resumen # noqa: E402
|
|
49
|
+
|
|
50
|
+
SESION = os.path.join(RAIZ, "plugin", "scripts", "city-session.sh")
|
|
51
|
+
GANCHO = os.path.join(RAIZ, "plugin", "hooks", "ask-the-house.sh")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── the doors, each one real ─────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def pide(puerto, ruta, metodo="GET", cuerpo=None):
|
|
58
|
+
url = f"http://127.0.0.1:{puerto}{ruta}"
|
|
59
|
+
url += ("&" if "?" in ruta else "?") + "PASE=" + serve.PASE
|
|
60
|
+
req = urllib.request.Request(
|
|
61
|
+
url, method=metodo, data=json.dumps(cuerpo).encode() if cuerpo is not None else None
|
|
62
|
+
)
|
|
63
|
+
try:
|
|
64
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
65
|
+
return r.status, json.loads(r.read() or b"{}")
|
|
66
|
+
except urllib.error.HTTPError as e:
|
|
67
|
+
try:
|
|
68
|
+
return e.code, json.loads(e.read() or b"{}")
|
|
69
|
+
except ValueError:
|
|
70
|
+
return e.code, {}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Mundo:
|
|
74
|
+
"""One machine's worth of isolation: its own app home, tmux server and PATH."""
|
|
75
|
+
|
|
76
|
+
def __init__(self):
|
|
77
|
+
self.base = os.path.realpath(tempfile.mkdtemp(prefix="agents-city-e2e-"))
|
|
78
|
+
self.hogar = os.path.join(self.base, "app")
|
|
79
|
+
self.tmux = os.path.join(self.base, "tmux")
|
|
80
|
+
self.repo = os.path.join(self.base, "codigo", "api")
|
|
81
|
+
self.papeles = os.path.join(self.base, "papeles", "manual")
|
|
82
|
+
for d in (self.hogar, self.tmux, os.path.join(self.repo, "app"), self.papeles):
|
|
83
|
+
os.makedirs(d, exist_ok=True)
|
|
84
|
+
open(os.path.join(self.repo, "app", "router.rb"), "w").write("# routes\n")
|
|
85
|
+
open(os.path.join(self.papeles, "guia.md"), "w").write("# guide\n")
|
|
86
|
+
self.datos = ""
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def entorno(self):
|
|
90
|
+
e = dict(os.environ)
|
|
91
|
+
e.update({
|
|
92
|
+
"HOME": self.base,
|
|
93
|
+
"AGENTS_CITY_HOME": self.hogar,
|
|
94
|
+
"AGENTS_CITY_USER": "e2e",
|
|
95
|
+
"TMUX_TMPDIR": self.tmux,
|
|
96
|
+
"CITY_SEARCH_IN": self.base,
|
|
97
|
+
"XDG_CACHE_HOME": os.path.join(self.base, "cache"),
|
|
98
|
+
})
|
|
99
|
+
if self.datos:
|
|
100
|
+
e["AGENTS_CITY_DATA"] = self.datos
|
|
101
|
+
return e
|
|
102
|
+
|
|
103
|
+
def corre(self, *orden, **extra):
|
|
104
|
+
env = self.entorno
|
|
105
|
+
env.update(extra)
|
|
106
|
+
return subprocess.run(
|
|
107
|
+
list(orden), capture_output=True, text=True, env=env, cwd=self.base, timeout=180
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def ventanas(self, sesion):
|
|
111
|
+
r = self.corre("tmux", "list-windows", "-t", sesion, "-F", "#W")
|
|
112
|
+
return [x for x in r.stdout.split() if x]
|
|
113
|
+
|
|
114
|
+
def paneles(self, sesion):
|
|
115
|
+
r = self.corre("tmux", "list-panes", "-a", "-t", sesion, "-F", "#W\t#{pane_id}")
|
|
116
|
+
fuera = {}
|
|
117
|
+
for linea in r.stdout.splitlines():
|
|
118
|
+
if "\t" in linea:
|
|
119
|
+
ventana, panel = linea.split("\t", 1)
|
|
120
|
+
fuera.setdefault(ventana, panel)
|
|
121
|
+
return fuera
|
|
122
|
+
|
|
123
|
+
def entorno_de_la_sesion(self, sesion):
|
|
124
|
+
"""What the launcher told the session. Read back from tmux, not composed."""
|
|
125
|
+
r = self.corre("tmux", "show-environment", "-t", sesion)
|
|
126
|
+
fuera = {}
|
|
127
|
+
for linea in r.stdout.splitlines():
|
|
128
|
+
if "=" in linea and not linea.startswith("-"):
|
|
129
|
+
k, v = linea.split("=", 1)
|
|
130
|
+
fuera[k] = v
|
|
131
|
+
return fuera
|
|
132
|
+
|
|
133
|
+
def limpia(self):
|
|
134
|
+
self.corre("tmux", "kill-server")
|
|
135
|
+
if self.datos:
|
|
136
|
+
detiene_hubs_de_ciudad(self.datos)
|
|
137
|
+
shutil.rmtree(self.base, ignore_errors=True)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def abre_la_sesion(mundo, usuario="e2e"):
|
|
141
|
+
"""The real launcher. It ends in `tmux attach`, which cannot succeed with no
|
|
142
|
+
terminal — the windows are built by then, which is what the Hall relies on
|
|
143
|
+
when it opens a city from the browser."""
|
|
144
|
+
return mundo.corre("bash", SESION, usuario)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def llama_al_gancho(mundo, sesion, herramienta, entrada, **extra):
|
|
148
|
+
"""One tool call through the real hook, in the environment the launcher wrote.
|
|
149
|
+
|
|
150
|
+
The seat's own identity (`CITY_BUS_ACTOR=seat`) is the one value added here:
|
|
151
|
+
the launcher writes it in front of the runtime's command line rather than
|
|
152
|
+
into the session, so tmux cannot hand it back. Everything else — which city,
|
|
153
|
+
which home, which owner — comes from what the launcher actually set.
|
|
154
|
+
"""
|
|
155
|
+
env = dict(mundo.entorno)
|
|
156
|
+
env.pop("AGENTS_CITY_DATA", None)
|
|
157
|
+
env.update(mundo.entorno_de_la_sesion(sesion))
|
|
158
|
+
env["CITY_BUS_ACTOR"] = "seat"
|
|
159
|
+
env["CLAUDE_PLUGIN_ROOT"] = os.path.join(RAIZ, "plugin")
|
|
160
|
+
env.update(extra)
|
|
161
|
+
r = subprocess.run(
|
|
162
|
+
["/bin/bash", GANCHO],
|
|
163
|
+
input=json.dumps({"tool_name": herramienta, "tool_input": entrada, "cwd": mundo.datos}),
|
|
164
|
+
capture_output=True, text=True, env=env, timeout=60,
|
|
165
|
+
)
|
|
166
|
+
try:
|
|
167
|
+
return json.loads(r.stdout or "{}"), r
|
|
168
|
+
except ValueError:
|
|
169
|
+
return {}, r
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def decision(salida):
|
|
173
|
+
return (salida.get("hookSpecificOutput") or {}).get("permissionDecision", "")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def motivo(salida):
|
|
177
|
+
return (salida.get("hookSpecificOutput") or {}).get("permissionDecisionReason", "")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ── the story ────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def main():
|
|
184
|
+
print("\n one city, end to end")
|
|
185
|
+
if not shutil.which("tmux"):
|
|
186
|
+
afirma("· tmux is available", False, "tmux missing")
|
|
187
|
+
return resumen("e2e")
|
|
188
|
+
|
|
189
|
+
mundo = Mundo()
|
|
190
|
+
servidor = serve.Servidor(("127.0.0.1", 0), serve.Manejador)
|
|
191
|
+
puerto = servidor.server_port
|
|
192
|
+
threading.Thread(target=servidor.serve_forever, daemon=True).start()
|
|
193
|
+
previo = {k: os.environ.get(k) for k in
|
|
194
|
+
("AGENTS_CITY_DATA", "AGENTS_CITY_HOME", "AGENTS_CITY_USER",
|
|
195
|
+
"CITY_SEARCH_IN", "XDG_CACHE_HOME", "HOME")}
|
|
196
|
+
registro_previo = serve.cities.REGISTRO
|
|
197
|
+
serve.cities.REGISTRO = os.path.join(mundo.base, "registro", "cities")
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
# ── 1. a city, through its own door ──────────────────────────────────
|
|
201
|
+
print(" a city, a seat, a house")
|
|
202
|
+
r = mundo.corre(sys.executable, os.path.join(RAIZ, "plugin", "scripts", "cities.py"),
|
|
203
|
+
"create", "home")
|
|
204
|
+
mundo.datos = os.path.join(mundo.hogar, "e2e", "home")
|
|
205
|
+
afirma(
|
|
206
|
+
"· `cities create` builds a city where a city goes",
|
|
207
|
+
r.returncode == 0 and os.path.isfile(os.path.join(mundo.datos, "city.yml")),
|
|
208
|
+
f"{r.stdout}{r.stderr}",
|
|
209
|
+
)
|
|
210
|
+
# The Hall serves whatever AGENTS_CITY_DATA points at; this process is
|
|
211
|
+
# the server, so it has to be told too.
|
|
212
|
+
os.environ.update({k: v for k, v in mundo.entorno.items() if k in previo})
|
|
213
|
+
|
|
214
|
+
# ── 2. the seat, through the Hall ────────────────────────────────────
|
|
215
|
+
st, _ = pide(puerto, "/api/ficha", metodo="POST",
|
|
216
|
+
cuerpo={"role": "cpto", "domain": "software"})
|
|
217
|
+
ficha = os.path.join(mundo.datos, "e2e.md")
|
|
218
|
+
afirma("· the browser writes the owner card", st == 200 and os.path.isfile(ficha),
|
|
219
|
+
f"{st}")
|
|
220
|
+
|
|
221
|
+
# ── 3. a house, and the ground it works ─────────────────────────────
|
|
222
|
+
st, _ = pide(puerto, "/api/agentes", metodo="POST",
|
|
223
|
+
cuerpo={"name": "api", "kind": "code", "role": "dev"})
|
|
224
|
+
comprueba("· and adds a house to it", st, 200)
|
|
225
|
+
# The frontmatter is what the launcher and the cage read; the body is
|
|
226
|
+
# what the SEAT reads. Both, or the city and its own chair disagree.
|
|
227
|
+
cuerpo_carta = open(ficha, encoding="utf-8").read().split("## Agents", 1)[-1]
|
|
228
|
+
cuerpo_carta = cuerpo_carta.split("## ", 1)[0]
|
|
229
|
+
afirma(
|
|
230
|
+
"· in the half the launcher reads and the half the seat reads",
|
|
231
|
+
"`api`" in cuerpo_carta and "0 mounts" in cuerpo_carta,
|
|
232
|
+
cuerpo_carta[:300],
|
|
233
|
+
)
|
|
234
|
+
st, montado = pide(puerto, "/api/montaje", metodo="POST",
|
|
235
|
+
cuerpo={"agent": "api", "add": mundo.repo})
|
|
236
|
+
carta = open(ficha, encoding="utf-8").read()
|
|
237
|
+
cuerpo_carta = carta.split("## Agents", 1)[-1].split("## ", 1)[0]
|
|
238
|
+
afirma(
|
|
239
|
+
"· and the ground it works lands on disk and on the card, in both too",
|
|
240
|
+
st == 200
|
|
241
|
+
and any(m["target"] == mundo.repo for m in montado.get("mounts", []))
|
|
242
|
+
and "mounts.api:" in carta
|
|
243
|
+
and "1 mount." in cuerpo_carta,
|
|
244
|
+
carta,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# ── 4. open the city ────────────────────────────────────────────────
|
|
248
|
+
print(" and then it is opened, and changed while it is open")
|
|
249
|
+
abre_la_sesion(mundo)
|
|
250
|
+
sesion = serve.cities.sesion("e2e", mundo.datos)
|
|
251
|
+
ventanas = mundo.ventanas(sesion)
|
|
252
|
+
afirma("· the session opens with the chair and the house",
|
|
253
|
+
"seat" in ventanas and "api" in ventanas, str(ventanas))
|
|
254
|
+
antes = mundo.paneles(sesion)
|
|
255
|
+
|
|
256
|
+
# ── 5. a second house, while the city is running ────────────────────
|
|
257
|
+
st, _ = pide(puerto, "/api/agentes", metodo="POST",
|
|
258
|
+
cuerpo={"name": "manual", "kind": "knowledge", "role": "seo"})
|
|
259
|
+
pide(puerto, "/api/montaje", metodo="POST",
|
|
260
|
+
cuerpo={"agent": "manual", "add": mundo.papeles})
|
|
261
|
+
afirma("· a house can be added to a city that is already open", st == 200)
|
|
262
|
+
afirma(
|
|
263
|
+
"· non-happy: and until the city is opened again, it is not there",
|
|
264
|
+
"manual" not in mundo.ventanas(sesion),
|
|
265
|
+
str(mundo.ventanas(sesion)),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# ── 6. open it again: reconcile, do not rebuild ─────────────────────
|
|
269
|
+
salida = abre_la_sesion(mundo)
|
|
270
|
+
ventanas = mundo.ventanas(sesion)
|
|
271
|
+
despues = mundo.paneles(sesion)
|
|
272
|
+
afirma("· opening it again brings the new house in", "manual" in ventanas, str(ventanas))
|
|
273
|
+
afirma(
|
|
274
|
+
"· non-happy: without disturbing a single window that was already there",
|
|
275
|
+
despues.get("seat") == antes.get("seat") and despues.get("api") == antes.get("api"),
|
|
276
|
+
f"{antes} → {despues}",
|
|
277
|
+
)
|
|
278
|
+
comprueba("· and the city is not opened twice", len(ventanas), 3)
|
|
279
|
+
afirma("· it says what it did", "manual" in salida.stderr, salida.stderr[-300:])
|
|
280
|
+
|
|
281
|
+
# A window whose agent left the card is somebody's work in progress.
|
|
282
|
+
mundo.corre("tmux", "new-window", "-t", sesion, "-n", "viejo")
|
|
283
|
+
salida = abre_la_sesion(mundo)
|
|
284
|
+
afirma(
|
|
285
|
+
"· non-happy: a window nobody's card claims is reported, never closed",
|
|
286
|
+
"viejo" in mundo.ventanas(sesion) and "viejo" in salida.stderr,
|
|
287
|
+
salida.stderr[-400:],
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# ── 7. the chair reaches into somebody's repo ───────────────────────
|
|
291
|
+
print(" and the chair is a chair")
|
|
292
|
+
del_lanzador = mundo.entorno_de_la_sesion(sesion)
|
|
293
|
+
afirma(
|
|
294
|
+
"· the launcher tells the window which city it belongs to",
|
|
295
|
+
del_lanzador.get("AGENTS_CITY_DATA") == mundo.datos,
|
|
296
|
+
str(del_lanzador),
|
|
297
|
+
)
|
|
298
|
+
propio, _ = llama_al_gancho(mundo, sesion, "Read", {"file_path": ficha})
|
|
299
|
+
comprueba("· happy: its own card is its own business", decision(propio), "")
|
|
300
|
+
ajeno, bruto = llama_al_gancho(
|
|
301
|
+
mundo, sesion, "Bash",
|
|
302
|
+
{"command": f"cd {mundo.repo} && grep -rn router app"},
|
|
303
|
+
)
|
|
304
|
+
comprueba("· non-happy: its agent's repo is not", decision(ajeno), "deny")
|
|
305
|
+
afirma(
|
|
306
|
+
"· and the refusal hands over the house, the role and the way to ask",
|
|
307
|
+
"api" in motivo(ajeno) and "dev" in motivo(ajeno)
|
|
308
|
+
and "--member api" in motivo(ajeno),
|
|
309
|
+
motivo(ajeno)[:400] or bruto.stderr[-300:],
|
|
310
|
+
)
|
|
311
|
+
otro, _ = llama_al_gancho(mundo, sesion, "Read",
|
|
312
|
+
{"file_path": os.path.join(mundo.papeles, "guia.md")})
|
|
313
|
+
afirma(
|
|
314
|
+
"· non-happy: and a folder of documents is ground too, with its own owner",
|
|
315
|
+
decision(otro) == "deny" and "manual" in motivo(otro),
|
|
316
|
+
motivo(otro)[:200],
|
|
317
|
+
)
|
|
318
|
+
puerta, _ = llama_al_gancho(
|
|
319
|
+
mundo, sesion, "Bash",
|
|
320
|
+
{"command": f'agents-city committee open --member api --question "about {mundo.repo}"'},
|
|
321
|
+
)
|
|
322
|
+
comprueba("· happy: and asking is never the thing that is stopped",
|
|
323
|
+
decision(puerta), "")
|
|
324
|
+
|
|
325
|
+
# ── 8. the refusal is on disk, and readable from the browser ────────
|
|
326
|
+
st, diario = pide(puerto, "/api/diario")
|
|
327
|
+
lineas = [x for x in (diario.get("lineas") or diario.get("entradas") or [])
|
|
328
|
+
if x.get("tipo") == "alcance"]
|
|
329
|
+
afirma(
|
|
330
|
+
"· the refusal is in the journal the owner can send me",
|
|
331
|
+
st == 200 and any(x.get("agente") == "api" for x in lineas),
|
|
332
|
+
json.dumps(diario)[:400],
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# ── 9. and the owner can open the chair's hands ─────────────────────
|
|
336
|
+
r = mundo.corre("bash", os.path.join(RAIZ, "bin", "seat"),
|
|
337
|
+
"--city", mundo.datos, "--seat-reach", "open")
|
|
338
|
+
abierto, _ = llama_al_gancho(
|
|
339
|
+
mundo, sesion, "Bash", {"command": f"grep -rn router {mundo.repo}"})
|
|
340
|
+
afirma(
|
|
341
|
+
"· happy: `--seat-reach open` is the owner's call, and it takes effect",
|
|
342
|
+
r.returncode == 0 and decision(abierto) == "",
|
|
343
|
+
f"{r.stdout}{r.stderr}\n{motivo(abierto)[:200]}",
|
|
344
|
+
)
|
|
345
|
+
r = mundo.corre("bash", os.path.join(RAIZ, "bin", "seat"),
|
|
346
|
+
"--city", mundo.datos, "--seat-reach", "closed")
|
|
347
|
+
cerrado, _ = llama_al_gancho(
|
|
348
|
+
mundo, sesion, "Bash", {"command": f"grep -rn router {mundo.repo}"})
|
|
349
|
+
comprueba("· and closing it again puts the boundary back",
|
|
350
|
+
decision(cerrado), "deny")
|
|
351
|
+
|
|
352
|
+
# ── 10. and after all of it, the city still describes itself ────────
|
|
353
|
+
st, estado = pide(puerto, "/api/estado")
|
|
354
|
+
nombres = sorted(a["name"] for a in estado.get("agents", []))
|
|
355
|
+
comprueba("· the city ends the day with the two houses it was given",
|
|
356
|
+
(st, nombres), (200, ["api", "manual"]))
|
|
357
|
+
finally:
|
|
358
|
+
servidor.shutdown()
|
|
359
|
+
servidor.server_close()
|
|
360
|
+
serve.cities.REGISTRO = registro_previo
|
|
361
|
+
for k, v in previo.items():
|
|
362
|
+
if v is None:
|
|
363
|
+
os.environ.pop(k, None)
|
|
364
|
+
else:
|
|
365
|
+
os.environ[k] = v
|
|
366
|
+
mundo.limpia()
|
|
367
|
+
return resumen("e2e")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
if __name__ == "__main__":
|
|
371
|
+
sys.exit(main())
|