agents-city 0.3.0-beta.21 → 0.3.0
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 +310 -70
- package/README.md +297 -69
- package/bin/agents-city.js +3 -0
- package/bin/doctor +3 -0
- package/bin/hall.html +164 -24
- package/bin/navegador.mjs +415 -0
- package/bin/serve.py +383 -127
- package/bin/shortcut +3 -0
- package/bin/test +5 -2
- package/bin/test-actualiza.py +130 -0
- package/bin/test-atajos.py +301 -0
- package/bin/test-busca.py +216 -0
- package/bin/test-cage.py +170 -2
- package/bin/test-card.py +2 -2
- package/bin/test-cities.py +45 -0
- package/bin/test-contracts.py +12 -5
- package/bin/test-doctor.py +33 -0
- package/bin/test-navegador.py +164 -0
- package/bin/test-seat.py +245 -25
- package/bin/test-serve.py +214 -9
- package/bin/test-workspace.py +63 -0
- package/bin/testlib.py +23 -0
- package/bin/update +3 -0
- package/city/web/dist/city.js +47 -47
- package/city/web/dist/index.html +1 -1
- package/city/web/dist-hall/hall.js +2193 -174
- package/city/web/src/bienvenida.ts +686 -0
- package/city/web/src/es.ts +180 -0
- package/city/web/src/hall.ts +520 -168
- package/city/web/src/idioma.ts +86 -0
- package/city/web/src/main.ts +27 -0
- package/city/web/src/motores.ts +54 -0
- package/docs/agents-first.md +8 -1
- package/docs/security.md +46 -12
- package/docs/testing.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/channel/bus.js +1 -1
- package/plugin/channel/bus.ts +1 -1
- package/plugin/channel/runtime/codex.ts +1 -1
- package/plugin/channel/runtime-gateway.js +1 -1
- package/plugin/scripts/actualiza.py +198 -0
- package/plugin/scripts/atajos.py +506 -0
- package/plugin/scripts/busca.py +436 -0
- package/plugin/scripts/cage.py +266 -26
- package/plugin/scripts/capabilities.py +17 -10
- package/plugin/scripts/card.py +10 -0
- package/plugin/scripts/cities.py +34 -0
- package/plugin/scripts/city-session.sh +33 -7
- package/plugin/scripts/doctor.py +122 -0
- package/plugin/scripts/find-repos.sh +12 -105
- package/plugin/scripts/read-card.py +6 -2
- package/plugin/scripts/report.py +5 -6
- package/plugin/scripts/reset.py +50 -14
- package/plugin/scripts/seat.py +445 -103
- package/plugin/scripts/workspace.py +197 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The disk scanner: repositories, worktrees and folders of documents.
|
|
3
|
+
|
|
4
|
+
The thing this suite really guards is that the scan finds what a person came to
|
|
5
|
+
find. The picker in the Hall is only as good as this index, and every mistake it
|
|
6
|
+
can make is silent — a repo missing from a list looks exactly like a repo that
|
|
7
|
+
was never cloned.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
|
|
16
|
+
AQUI = os.path.dirname(os.path.abspath(__file__))
|
|
17
|
+
RAIZ = os.path.dirname(AQUI)
|
|
18
|
+
sys.path.insert(0, AQUI)
|
|
19
|
+
sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
|
|
20
|
+
|
|
21
|
+
import busca # noqa: E402
|
|
22
|
+
from testlib import afirma, comprueba, resumen # noqa: E402
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def git(*args, cwd):
|
|
26
|
+
subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=False)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def monta(raiz):
|
|
30
|
+
"""A believable disk: two clones, a linked worktree, a vault of notes, a
|
|
31
|
+
folder of documents inside a repo, and a lot of noise to walk past."""
|
|
32
|
+
clon = os.path.join(raiz, "codigo", "el-repo")
|
|
33
|
+
os.makedirs(clon)
|
|
34
|
+
git("init", "-q", cwd=clon)
|
|
35
|
+
git("remote", "add", "origin", "git@github.com:alguien/nombre-remoto.git", cwd=clon)
|
|
36
|
+
open(os.path.join(clon, "a.txt"), "w").close()
|
|
37
|
+
git("add", "-A", cwd=clon)
|
|
38
|
+
git("-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "one", cwd=clon)
|
|
39
|
+
|
|
40
|
+
otro = os.path.join(raiz, "codigo", "otro")
|
|
41
|
+
os.makedirs(otro)
|
|
42
|
+
git("init", "-q", cwd=otro)
|
|
43
|
+
git("remote", "add", "origin", "https://gitlab.com/otra-org/segundo.git", cwd=otro)
|
|
44
|
+
|
|
45
|
+
# A linked worktree: `.git` is a FILE here, which is exactly the case the
|
|
46
|
+
# first version of the scanner missed.
|
|
47
|
+
arbol = os.path.join(raiz, "arboles", "rama-viva")
|
|
48
|
+
git("worktree", "add", "-q", "-b", "rama-viva", arbol, cwd=clon)
|
|
49
|
+
|
|
50
|
+
# Documents with no git anywhere near them.
|
|
51
|
+
boveda = os.path.join(raiz, "Documents", "handbook")
|
|
52
|
+
os.makedirs(boveda)
|
|
53
|
+
for n in ("uno.md", "dos.md", "tres.pdf"):
|
|
54
|
+
open(os.path.join(boveda, n), "w").close()
|
|
55
|
+
|
|
56
|
+
# Documents INSIDE a repo: reachable through the repo already.
|
|
57
|
+
dentro = os.path.join(clon, "docs")
|
|
58
|
+
os.makedirs(dentro)
|
|
59
|
+
for n in ("a.md", "b.md", "c.md"):
|
|
60
|
+
open(os.path.join(dentro, n), "w").close()
|
|
61
|
+
|
|
62
|
+
# Two loose files are not a knowledge base.
|
|
63
|
+
flojo = os.path.join(raiz, "Documents", "sueltos")
|
|
64
|
+
os.makedirs(flojo)
|
|
65
|
+
for n in ("solo.md", "otro.md"):
|
|
66
|
+
open(os.path.join(flojo, n), "w").close()
|
|
67
|
+
|
|
68
|
+
# Noise the scan must never walk into.
|
|
69
|
+
for basura in ("node_modules", ".cache", "Library"):
|
|
70
|
+
ruta = os.path.join(raiz, "codigo", basura, "paquete")
|
|
71
|
+
os.makedirs(ruta)
|
|
72
|
+
git("init", "-q", cwd=ruta)
|
|
73
|
+
git("remote", "add", "origin", "https://x/y/basura.git", cwd=ruta)
|
|
74
|
+
return clon, arbol, boveda
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def escaneo(raiz):
|
|
78
|
+
print(" what the scan finds")
|
|
79
|
+
clon, arbol, boveda = monta(raiz)
|
|
80
|
+
sitios = busca.escanea()
|
|
81
|
+
por_ruta = {s["ruta"]: s for s in sitios}
|
|
82
|
+
nombres = {s["nombre"] for s in sitios}
|
|
83
|
+
|
|
84
|
+
afirma("· a clone is named by its remote, not by its folder",
|
|
85
|
+
"nombre-remoto" in nombres, str(sorted(nombres)))
|
|
86
|
+
afirma("· an https remote is named too", "segundo" in nombres, str(sorted(nombres)))
|
|
87
|
+
comprueba("· and it is a repo", por_ruta[clon]["clase"], "repo")
|
|
88
|
+
|
|
89
|
+
afirma("· a linked worktree is found at all", arbol in por_ruta, str(sorted(por_ruta)))
|
|
90
|
+
comprueba("· and named repo@branch", por_ruta[arbol]["nombre"], "nombre-remoto@rama-viva")
|
|
91
|
+
comprueba("· and marked as a worktree", por_ruta[arbol]["clase"], "worktree")
|
|
92
|
+
|
|
93
|
+
afirma("· a folder of documents with no git is offered",
|
|
94
|
+
boveda in por_ruta, str(sorted(por_ruta)))
|
|
95
|
+
comprueba("· and marked as documents", por_ruta[boveda]["clase"], "docs")
|
|
96
|
+
afirma("· a repo's own docs/ is not offered separately",
|
|
97
|
+
os.path.join(clon, "docs") not in por_ruta, str(sorted(por_ruta)))
|
|
98
|
+
afirma("· two loose files are not a knowledge base",
|
|
99
|
+
os.path.join(raiz, "Documents", "sueltos") not in por_ruta, str(sorted(por_ruta)))
|
|
100
|
+
|
|
101
|
+
afirma("· nothing under node_modules, .cache or Library is indexed",
|
|
102
|
+
not any("node_modules" in r or ".cache" in r or "/Library/" in r for r in por_ruta),
|
|
103
|
+
str(sorted(por_ruta)))
|
|
104
|
+
afirma("· every place has a last-touched time",
|
|
105
|
+
all(s.get("cuando", 0) > 0 for s in sitios), str(sitios))
|
|
106
|
+
afirma("· most recently touched first",
|
|
107
|
+
[s["cuando"] for s in sitios] == sorted((s["cuando"] for s in sitios), reverse=True),
|
|
108
|
+
str([(s["nombre"], s["cuando"]) for s in sitios]))
|
|
109
|
+
return clon
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cache(raiz, clon):
|
|
113
|
+
print(" the cache")
|
|
114
|
+
fichero = busca.fichero_cache()
|
|
115
|
+
afirma("· nothing cached yet", not os.path.exists(fichero), fichero)
|
|
116
|
+
primera = busca.lugares()
|
|
117
|
+
afirma("· the first call writes the index", os.path.isfile(fichero), fichero)
|
|
118
|
+
afirma("· and a second call is served from it", busca.lugares() == primera, "")
|
|
119
|
+
|
|
120
|
+
# A repo cloned after the index was built is invisible until asked again —
|
|
121
|
+
# that is the honest behaviour, and the reason the Hall has a button.
|
|
122
|
+
nuevo = os.path.join(raiz, "codigo", "recien")
|
|
123
|
+
os.makedirs(nuevo)
|
|
124
|
+
git("init", "-q", cwd=nuevo)
|
|
125
|
+
git("remote", "add", "origin", "https://x/y/recien-clonado.git", cwd=nuevo)
|
|
126
|
+
afirma("· a just-cloned repo is not in the stale index",
|
|
127
|
+
"recien-clonado" not in {s["nombre"] for s in busca.lugares()}, "")
|
|
128
|
+
afirma("· asking again finds it",
|
|
129
|
+
"recien-clonado" in {s["nombre"] for s in busca.lugares(refrescar=True)}, "")
|
|
130
|
+
comprueba("· and by name", busca.ruta_de("recien-clonado"), nuevo)
|
|
131
|
+
comprueba("· a name nobody has is nothing, not a guess", busca.ruta_de("no-existe"), "")
|
|
132
|
+
|
|
133
|
+
guardado = busca.lugares()
|
|
134
|
+
afirma("· the cached rows survive the round trip intact",
|
|
135
|
+
all(s["clase"] and s["nombre"] and s["ruta"] for s in guardado), str(guardado))
|
|
136
|
+
afirma("· repos() is the git half only",
|
|
137
|
+
all(not r[0].startswith("handbook") for r in busca.repos())
|
|
138
|
+
and any(n == "nombre-remoto" for n, _ in busca.repos()), str(busca.repos()))
|
|
139
|
+
comprueba("· the clone is still where it was", dict(busca.repos())["nombre-remoto"], clon)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def filtros(raiz):
|
|
143
|
+
print(" the search aperture")
|
|
144
|
+
os.environ["AGENTS_CITY_ORG"] = "otra-org"
|
|
145
|
+
solo = {s["nombre"] for s in busca.escanea()}
|
|
146
|
+
afirma("· an org filter keeps that org", "segundo" in solo, str(sorted(solo)))
|
|
147
|
+
afirma("· and drops the others", "nombre-remoto" not in solo, str(sorted(solo)))
|
|
148
|
+
del os.environ["AGENTS_CITY_ORG"]
|
|
149
|
+
afirma("· unset means index what is there",
|
|
150
|
+
"nombre-remoto" in {s["nombre"] for s in busca.escanea()}, "")
|
|
151
|
+
|
|
152
|
+
hondo = os.path.join(raiz, "a", "b", "c", "d", "e", "hondo")
|
|
153
|
+
os.makedirs(hondo)
|
|
154
|
+
git("init", "-q", cwd=hondo)
|
|
155
|
+
git("remote", "add", "origin", "https://x/y/muy-hondo.git", cwd=hondo)
|
|
156
|
+
os.environ["CITY_SEARCH_DEPTH"] = "2"
|
|
157
|
+
afirma("· depth is respected",
|
|
158
|
+
"muy-hondo" not in {s["nombre"] for s in busca.escanea()}, "")
|
|
159
|
+
os.environ["CITY_SEARCH_DEPTH"] = "9"
|
|
160
|
+
afirma("· and a deeper aperture reaches it",
|
|
161
|
+
"muy-hondo" in {s["nombre"] for s in busca.escanea()}, "")
|
|
162
|
+
os.environ["CITY_SEARCH_DEPTH"] = "4"
|
|
163
|
+
|
|
164
|
+
print(" the roots")
|
|
165
|
+
anidada = os.pathsep.join([raiz, os.path.join(raiz, "codigo")])
|
|
166
|
+
os.environ["CITY_SEARCH_IN"] = anidada
|
|
167
|
+
afirma("· a root already covered by another is not walked twice",
|
|
168
|
+
len(busca.raices()) == 1, str(busca.raices()))
|
|
169
|
+
os.environ["CITY_SEARCH_IN"] = os.pathsep.join([raiz, os.path.join(raiz, "no-existe")])
|
|
170
|
+
afirma("· a root that does not exist is skipped, not an error",
|
|
171
|
+
len(busca.raices()) == 1, str(busca.raices()))
|
|
172
|
+
os.environ["CITY_SEARCH_IN"] = raiz
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def compatibilidad(raiz):
|
|
176
|
+
if os.name != "posix":
|
|
177
|
+
# The shim is bash, for bash callers. Its absence is the entire reason
|
|
178
|
+
# the scanner beneath it stopped being a shell script.
|
|
179
|
+
return
|
|
180
|
+
print(" the shell shim")
|
|
181
|
+
guion = os.path.join(RAIZ, "plugin", "scripts", "find-repos.sh")
|
|
182
|
+
r = subprocess.run([guion], capture_output=True, text=True, timeout=120, env=os.environ)
|
|
183
|
+
lineas = [l for l in r.stdout.splitlines() if "\t" in l]
|
|
184
|
+
afirma("· find-repos.sh still prints name<TAB>path", bool(lineas), r.stdout + r.stderr)
|
|
185
|
+
afirma("· and only git places", all(len(l.split("\t")) == 2 for l in lineas), r.stdout)
|
|
186
|
+
afirma("· no document folder leaks into the git-only contract",
|
|
187
|
+
not any("handbook" in l for l in lineas), r.stdout)
|
|
188
|
+
uno = subprocess.run([guion, "nombre-remoto"], capture_output=True, text=True,
|
|
189
|
+
timeout=120, env=os.environ)
|
|
190
|
+
comprueba("· and one name still resolves to one path",
|
|
191
|
+
uno.stdout.strip(), os.path.join(raiz, "codigo", "el-repo"))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def main():
|
|
195
|
+
raiz = tempfile.mkdtemp()
|
|
196
|
+
cache_dir = tempfile.mkdtemp()
|
|
197
|
+
viejo = dict(os.environ)
|
|
198
|
+
try:
|
|
199
|
+
os.environ["CITY_SEARCH_IN"] = raiz
|
|
200
|
+
os.environ["CITY_SEARCH_DEPTH"] = "4"
|
|
201
|
+
os.environ["XDG_CACHE_HOME"] = cache_dir
|
|
202
|
+
os.environ.pop("AGENTS_CITY_ORG", None)
|
|
203
|
+
clon = escaneo(raiz)
|
|
204
|
+
cache(raiz, clon)
|
|
205
|
+
filtros(raiz)
|
|
206
|
+
compatibilidad(raiz)
|
|
207
|
+
finally:
|
|
208
|
+
os.environ.clear()
|
|
209
|
+
os.environ.update(viejo)
|
|
210
|
+
shutil.rmtree(raiz, ignore_errors=True)
|
|
211
|
+
shutil.rmtree(cache_dir, ignore_errors=True)
|
|
212
|
+
return resumen("busca")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
sys.exit(main())
|
package/bin/test-cage.py
CHANGED
|
@@ -23,8 +23,19 @@ import cage # noqa: E402
|
|
|
23
23
|
from testlib import afirma, comprueba, resumen # noqa: E402
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def entorno_falso():
|
|
27
|
-
|
|
26
|
+
def entorno_falso(como_un_home_real=False):
|
|
27
|
+
"""A throwaway HOME with credentials planted in it.
|
|
28
|
+
|
|
29
|
+
`como_un_home_real` puts it under the caller's own home instead of /tmp,
|
|
30
|
+
and that is not cosmetic: the Linux cage keeps /tmp writable, so a fixture
|
|
31
|
+
living there is writable through-and-through and CANNOT reproduce what a
|
|
32
|
+
real `/home/you` does. A launch-blocking bug shipped behind exactly that
|
|
33
|
+
blind spot — the suite was green while no real user could start a window.
|
|
34
|
+
"""
|
|
35
|
+
if como_un_home_real:
|
|
36
|
+
base = tempfile.mkdtemp(prefix=".agents-city-cage-", dir=os.path.expanduser("~"))
|
|
37
|
+
else:
|
|
38
|
+
base = tempfile.mkdtemp(prefix="agents-city-cage-")
|
|
28
39
|
casa = os.path.join(base, "home")
|
|
29
40
|
repo = os.path.join(base, "repo")
|
|
30
41
|
os.makedirs(os.path.join(casa, ".ssh"))
|
|
@@ -199,8 +210,165 @@ def jaula_viva():
|
|
|
199
210
|
shutil.rmtree(base, ignore_errors=True)
|
|
200
211
|
|
|
201
212
|
|
|
213
|
+
def argv_de_linux():
|
|
214
|
+
"""The Linux argv, checked on whatever machine runs this.
|
|
215
|
+
|
|
216
|
+
The live namespace checks below only run on Linux, which means a macOS-only
|
|
217
|
+
contributor could break the Linux cage and see every suite pass — the shape
|
|
218
|
+
of bug this repo has already met once. The ORDER is the security invariant
|
|
219
|
+
(last mount wins), so the order is asserted everywhere.
|
|
220
|
+
"""
|
|
221
|
+
print(" the Linux cage's argv, from whichever kernel we are on")
|
|
222
|
+
base, casa, repo = entorno_falso()
|
|
223
|
+
try:
|
|
224
|
+
# Every path in the argv is canonical (on macOS /var resolves under
|
|
225
|
+
# /private), so the expectations resolve the same way the cage does.
|
|
226
|
+
real = cage.rutas.canonicaliza
|
|
227
|
+
argv = cage.argv_bwrap(repo, casa=casa)
|
|
228
|
+
texto = " ".join(argv)
|
|
229
|
+
afirma("it starts by binding the whole filesystem read-only",
|
|
230
|
+
argv[:5] == ["bwrap", "--ro-bind", "/", "/", "--dev"], " ".join(argv[:6]))
|
|
231
|
+
i_repo = argv.index(real(repo))
|
|
232
|
+
i_ssh = argv.index(real(os.path.join(casa, ".ssh")))
|
|
233
|
+
afirma("the writable working set is bound before the seals are applied",
|
|
234
|
+
i_repo < i_ssh, f"repo at {i_repo}, sealed .ssh at {i_ssh}")
|
|
235
|
+
afirma("a sealed directory becomes an empty tmpfs, not a refusal",
|
|
236
|
+
f"--tmpfs {real(os.path.join(casa, '.ssh'))}" in texto, texto[-400:])
|
|
237
|
+
afirma("a sealed FILE reads as nothing instead",
|
|
238
|
+
f"--ro-bind-try /dev/null {real(os.path.join(casa, '.git-credentials'))}" in texto,
|
|
239
|
+
texto[-400:])
|
|
240
|
+
afirma("the window keeps its own repo writable",
|
|
241
|
+
f"--bind-try {real(repo)} {real(repo)}" in texto, texto[:400])
|
|
242
|
+
# NOT --die-with-parent, and not by omission: the bus hub is started
|
|
243
|
+
# detached on purpose, so tying the namespace to one pane would take
|
|
244
|
+
# the city's bus down with whichever window happened to start it.
|
|
245
|
+
afirma("the namespace does not tie itself to one pane's lifetime",
|
|
246
|
+
"--die-with-parent" not in argv)
|
|
247
|
+
# /proc arrives with the read-only bind of /. Mounting a fresh one
|
|
248
|
+
# without a PID namespace buys nothing, and WITH one it would make the
|
|
249
|
+
# gateway record a namespace-local pid that `agents-city exit` then
|
|
250
|
+
# signals on the host.
|
|
251
|
+
afirma("no separate /proc mount, so recorded pids stay host pids",
|
|
252
|
+
"--proc" not in argv)
|
|
253
|
+
afirma("the device tree is the sandbox's own, not the host's re-bound",
|
|
254
|
+
argv.count("/dev") == 1, " ".join(a for a in argv if "dev" in a))
|
|
255
|
+
afirma("the probe runs exactly the flags a real launch starts from",
|
|
256
|
+
argv[:len(cage.BASE_BWRAP)] == cage.BASE_BWRAP, " ".join(argv[:8]))
|
|
257
|
+
afirma("Claude's own config file at the HOME root stays writable",
|
|
258
|
+
f"--bind-try {real(os.path.join(casa, '.claude.json'))}" in texto,
|
|
259
|
+
texto[-300:])
|
|
260
|
+
# The broker token is the one file re-admitted, and it must come after
|
|
261
|
+
# the seal that hides the directory it lives in — last mount wins.
|
|
262
|
+
token = os.path.join(casa, ".agents-city", ".runtime", "broker", "web.token")
|
|
263
|
+
os.makedirs(os.path.dirname(token), exist_ok=True)
|
|
264
|
+
open(token, "w").write("t")
|
|
265
|
+
conficha = cage.argv_bwrap(repo, casa=casa, fichero_token=token)
|
|
266
|
+
i_sello = max(i for i, a in enumerate(conficha)
|
|
267
|
+
if a.endswith(os.path.join(".runtime", "broker")))
|
|
268
|
+
i_token = max(i for i, a in enumerate(conficha) if a == real(token))
|
|
269
|
+
afirma("this window's own token is re-admitted after the broker seal",
|
|
270
|
+
i_token > i_sello, f"seal at {i_sello}, token at {i_token}")
|
|
271
|
+
# A mount that resolves inside a sealed root must never become writable.
|
|
272
|
+
dentro = os.path.join(casa, ".ssh", "robado")
|
|
273
|
+
os.makedirs(dentro, exist_ok=True)
|
|
274
|
+
con_fuga = " ".join(cage.argv_bwrap(repo, casa=casa, extra_escritura=(dentro,)))
|
|
275
|
+
afirma("a mount inside a sealed root is dropped, not bound writable",
|
|
276
|
+
f"--bind-try {real(dentro)}" not in con_fuga, con_fuga[-300:])
|
|
277
|
+
finally:
|
|
278
|
+
shutil.rmtree(base, ignore_errors=True)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def sonda_compartida():
|
|
282
|
+
"""The bubblewrap probe is paid once per city, not once per window.
|
|
283
|
+
|
|
284
|
+
`cage.py` runs as a fresh process per window, so an in-process memo dies
|
|
285
|
+
every time: without a channel between them, a city of eight agents forks
|
|
286
|
+
eight namespaces before the first prompt — and on a kernel that refuses
|
|
287
|
+
them, forks eight failures.
|
|
288
|
+
"""
|
|
289
|
+
print(" the probe that must not be paid per window")
|
|
290
|
+
previo = os.environ.get("CITY_CAGE_BWRAP")
|
|
291
|
+
try:
|
|
292
|
+
os.environ["CITY_CAGE_BWRAP"] = "0"
|
|
293
|
+
afirma("an owner-supplied answer is believed without probing",
|
|
294
|
+
cage.bwrap_sirve() is False)
|
|
295
|
+
os.environ["CITY_CAGE_BWRAP"] = "1"
|
|
296
|
+
# Believed, but never blindly: tmux windows inherit the server's whole
|
|
297
|
+
# environment, so a value carried over from another machine would build
|
|
298
|
+
# a prefix that exits 127 on every window. The binary still has to be
|
|
299
|
+
# there — which is why this asserts agreement with `which`, not True.
|
|
300
|
+
afirma("an affirmative answer still requires bwrap to exist here",
|
|
301
|
+
cage.bwrap_sirve() == (shutil.which("bwrap") is not None))
|
|
302
|
+
finally:
|
|
303
|
+
if previo is None:
|
|
304
|
+
os.environ.pop("CITY_CAGE_BWRAP", None)
|
|
305
|
+
else:
|
|
306
|
+
os.environ["CITY_CAGE_BWRAP"] = previo
|
|
307
|
+
lanzador = open(os.path.join(RAIZ, "plugin", "scripts", "city-session.sh"),
|
|
308
|
+
encoding="utf-8").read()
|
|
309
|
+
afirma("the launcher asks once and exports the answer to every window",
|
|
310
|
+
"export CITY_CAGE_BWRAP" in lanzador and lanzador.count("$CAGE\" check") <= 1,
|
|
311
|
+
"")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def jaula_linux():
|
|
315
|
+
"""The Linux cage, exercised against a real namespace.
|
|
316
|
+
|
|
317
|
+
Same guarantees as the macOS block above, proven the same way: a planted
|
|
318
|
+
key must be unreadable, the repo must stay writable, and a grandchild must
|
|
319
|
+
not escape. Different kernel, identical promise — which is the only reason
|
|
320
|
+
it is allowed to be a different mechanism.
|
|
321
|
+
"""
|
|
322
|
+
if not sys.platform.startswith("linux"):
|
|
323
|
+
print(" (live bubblewrap checks skipped: not Linux)")
|
|
324
|
+
return
|
|
325
|
+
if not cage.bwrap_sirve():
|
|
326
|
+
if os.environ.get("CITY_CAGE_REQUIRED") == "1":
|
|
327
|
+
afirma("the Linux cage is available where it was required", False,
|
|
328
|
+
"bwrap missing or namespaces refused, and CITY_CAGE_REQUIRED=1")
|
|
329
|
+
return
|
|
330
|
+
print(" (live bubblewrap checks skipped: bwrap missing or namespaces refused)")
|
|
331
|
+
return
|
|
332
|
+
base, casa, repo = entorno_falso(como_un_home_real=True)
|
|
333
|
+
try:
|
|
334
|
+
def dentro(*orden):
|
|
335
|
+
argv = cage.argv_bwrap(repo, casa=casa)
|
|
336
|
+
return subprocess.run([*argv, *orden], capture_output=True, text=True)
|
|
337
|
+
|
|
338
|
+
# First: the cage must START. Four of the checks below are vacuous if
|
|
339
|
+
# bwrap dies — "the key was unreadable" is also true when nothing ran.
|
|
340
|
+
arranque = dentro("/bin/echo", "in")
|
|
341
|
+
afirma("live: a window actually launches inside the cage",
|
|
342
|
+
arranque.returncode == 0 and arranque.stdout.strip() == "in",
|
|
343
|
+
arranque.stderr or arranque.stdout)
|
|
344
|
+
r = dentro("/bin/cat", os.path.join(casa, ".ssh", "id_ed25519"))
|
|
345
|
+
afirma("live: the planted SSH key is unreadable inside the cage",
|
|
346
|
+
r.returncode != 0 and "FAKE-PRIVATE-KEY" not in r.stdout, r.stdout or r.stderr)
|
|
347
|
+
r = dentro("/bin/cat", os.path.join(casa, ".git-credentials"))
|
|
348
|
+
afirma("live: the planted git credentials are unreachable inside the cage",
|
|
349
|
+
r.returncode != 0 and "FAKE-TOKEN" not in r.stdout, r.stdout or r.stderr)
|
|
350
|
+
r = dentro("/bin/sh", "-c", f"echo ok > {repo}/build.txt && cat {repo}/build.txt")
|
|
351
|
+
afirma("live: the window still writes freely in its own repo",
|
|
352
|
+
r.returncode == 0 and r.stdout.strip() == "ok", r.stderr)
|
|
353
|
+
fuga = f"echo leak > {casa}/.ssh/stolen.txt; cat {casa}/.ssh/stolen.txt"
|
|
354
|
+
r = dentro("/bin/sh", "-c", fuga)
|
|
355
|
+
afirma("live: a write into a sealed directory never reaches the real disk",
|
|
356
|
+
not os.path.exists(os.path.join(casa, ".ssh", "stolen.txt")), r.stdout or r.stderr)
|
|
357
|
+
r = dentro("/bin/sh", "-c", f"/bin/sh -c 'cat {casa}/.ssh/id_ed25519'")
|
|
358
|
+
afirma("live: a grandchild process inherits the cage",
|
|
359
|
+
r.returncode != 0 and "FAKE-PRIVATE-KEY" not in r.stdout)
|
|
360
|
+
linea = cage.linea(repo, "win one", casa=casa)
|
|
361
|
+
afirma("live: linea() hands back a bwrap prefix on Linux",
|
|
362
|
+
linea.startswith("bwrap ") and linea.endswith(" "), linea[:80])
|
|
363
|
+
finally:
|
|
364
|
+
shutil.rmtree(base, ignore_errors=True)
|
|
365
|
+
|
|
366
|
+
|
|
202
367
|
texto_del_perfil()
|
|
203
368
|
excepciones_y_errores()
|
|
204
369
|
mounts_agente_primero()
|
|
205
370
|
jaula_viva()
|
|
371
|
+
argv_de_linux()
|
|
372
|
+
sonda_compartida()
|
|
373
|
+
jaula_linux()
|
|
206
374
|
sys.exit(resumen("cage"))
|
package/bin/test-card.py
CHANGED
|
@@ -24,7 +24,7 @@ sys.path.insert(0, os.path.join(RAIZ, 'plugin', 'scripts'))
|
|
|
24
24
|
sys.path.insert(0, AQUI)
|
|
25
25
|
import card # noqa: E402
|
|
26
26
|
|
|
27
|
-
from testlib import comprueba, afirma, resumen # noqa: E402
|
|
27
|
+
from testlib import comprueba, afirma, resumen, roster # noqa: E402
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
def ficha(cuerpo):
|
|
@@ -370,7 +370,7 @@ def de_verdad():
|
|
|
370
370
|
'command': 'echo 1', 'baseline': '0', 'target': '1', 'by': 'Q3'}
|
|
371
371
|
casa = tempfile.mkdtemp()
|
|
372
372
|
porSeat = os.path.join(casa, 'ana.md')
|
|
373
|
-
seat.escribe_ficha(porSeat, 'ana', 'cpto',
|
|
373
|
+
seat.escribe_ficha(porSeat, 'ana', 'cpto', roster(('api',)), meta)
|
|
374
374
|
|
|
375
375
|
otro = os.path.join(casa, 'wiz')
|
|
376
376
|
os.makedirs(otro + '/roles', exist_ok=True)
|
package/bin/test-cities.py
CHANGED
|
@@ -438,6 +438,50 @@ def skills_transparentes():
|
|
|
438
438
|
comprueba("· there is no stale capability cache", nombres, ["safe-deploy", "audit"])
|
|
439
439
|
|
|
440
440
|
|
|
441
|
+
def reset_de_varias():
|
|
442
|
+
"""`reset` takes several cities, or `all` — and stops before touching any
|
|
443
|
+
of them when one name is wrong.
|
|
444
|
+
|
|
445
|
+
Resetting three of four cities and then failing on a typo is the worst
|
|
446
|
+
possible outcome of a destructive command, so resolution happens first and
|
|
447
|
+
completely.
|
|
448
|
+
"""
|
|
449
|
+
print(" resetting more than one city")
|
|
450
|
+
base = tempfile.mkdtemp()
|
|
451
|
+
previo = {k: os.environ.get(k) for k in ("AGENTS_CITY_HOME", "AGENTS_CITY_USER")}
|
|
452
|
+
os.environ.update(AGENTS_CITY_HOME=base, AGENTS_CITY_USER="ana")
|
|
453
|
+
registro = cities.REGISTRO
|
|
454
|
+
cities.REGISTRO = os.path.join(base, "reg")
|
|
455
|
+
try:
|
|
456
|
+
for n in ("home", "producto", "cliente"):
|
|
457
|
+
cities.crea("ana", n)
|
|
458
|
+
entorno = dict(os.environ, AGENTS_CITY_HOME=base, AGENTS_CITY_USER="ana")
|
|
459
|
+
guion = os.path.join(RAIZ, "plugin", "scripts", "reset.py")
|
|
460
|
+
|
|
461
|
+
r = subprocess.run(["python3", guion, "all", "--dry-run"],
|
|
462
|
+
capture_output=True, text=True, env=entorno)
|
|
463
|
+
comprueba("· `all` covers every city this owner has",
|
|
464
|
+
sum(1 for ln in r.stdout.splitlines() if "Would reset" in ln), 3)
|
|
465
|
+
r = subprocess.run(["python3", guion, "home", "cliente", "--dry-run"],
|
|
466
|
+
capture_output=True, text=True, env=entorno)
|
|
467
|
+
afirma("· several names, space separated, cover exactly those",
|
|
468
|
+
"`home`" in r.stdout and "`cliente`" in r.stdout and "`producto`" not in r.stdout,
|
|
469
|
+
r.stdout)
|
|
470
|
+
r = subprocess.run(["python3", guion, "home", "inventada", "--dry-run"],
|
|
471
|
+
capture_output=True, text=True, env=entorno)
|
|
472
|
+
afirma("· one unknown name aborts the whole run, before any city is touched",
|
|
473
|
+
r.returncode == 1 and "Nothing was reset" in r.stderr, r.stderr)
|
|
474
|
+
comprueba("· so nothing was reset", len(cities.lista("ana")), 3)
|
|
475
|
+
finally:
|
|
476
|
+
cities.REGISTRO = registro
|
|
477
|
+
for k, v in previo.items():
|
|
478
|
+
if v is None:
|
|
479
|
+
os.environ.pop(k, None)
|
|
480
|
+
else:
|
|
481
|
+
os.environ[k] = v
|
|
482
|
+
shutil.rmtree(base, ignore_errors=True)
|
|
483
|
+
|
|
484
|
+
|
|
441
485
|
def main():
|
|
442
486
|
print()
|
|
443
487
|
identidad_y_catalogo()
|
|
@@ -446,6 +490,7 @@ def main():
|
|
|
446
490
|
carreteras()
|
|
447
491
|
reinicio_aislado()
|
|
448
492
|
skills_transparentes()
|
|
493
|
+
reset_de_varias()
|
|
449
494
|
return resumen("cities")
|
|
450
495
|
|
|
451
496
|
|
package/bin/test-contracts.py
CHANGED
|
@@ -435,11 +435,10 @@ def documentacion_publica():
|
|
|
435
435
|
runtime, or release without documenting it in both supported languages.
|
|
436
436
|
"""
|
|
437
437
|
print(' bilingual public documentation')
|
|
438
|
-
package = json.load(open(os.path.join(RAIZ, 'package.json'), encoding='utf-8'))
|
|
439
438
|
public_commands = [
|
|
440
439
|
'hall', 'seat', 'cities', 'road', 'bus', 'committee', 'benchmark',
|
|
441
440
|
'reset', 'skills', 'city', 'demo', 'setup', 'report', 'tokens', 'exit',
|
|
442
|
-
'test',
|
|
441
|
+
'test', 'shortcut', 'doctor', 'update',
|
|
443
442
|
]
|
|
444
443
|
public_contract = [
|
|
445
444
|
'--help', '--version', '--no-browser', '--out', '--tui', '--repos',
|
|
@@ -499,9 +498,17 @@ def documentacion_publica():
|
|
|
499
498
|
afirma(f'· {filename} carries eighteen reproducible use cases',
|
|
500
499
|
content.count(cookbook_prefix) == 18,
|
|
501
500
|
f'found {content.count(cookbook_prefix)}')
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
501
|
+
# The install instructions must not name a versioned tarball. They used
|
|
502
|
+
# to, and every release left a README telling newcomers to install a
|
|
503
|
+
# file that no longer existed — so the contract was "repeat the current
|
|
504
|
+
# version everywhere". A glob cannot go stale, which is the better
|
|
505
|
+
# answer: what is pinned here is that nobody re-introduces the trap.
|
|
506
|
+
tarballs_fijos = re.findall(r'agents-city-\d[\w.\-]*\.tgz', content)
|
|
507
|
+
afirma(f'· {filename} installs by glob, never a tarball name that goes stale',
|
|
508
|
+
not tarballs_fijos, 'pinned: ' + ', '.join(sorted(set(tarballs_fijos))))
|
|
509
|
+
afirma(f'· {filename} opens with the one command that installs from npm',
|
|
510
|
+
'npm install -g agents-city' in content.split('## ')[0],
|
|
511
|
+
'the first screen does not show the npm install line')
|
|
505
512
|
afirma(f'· {filename} links to the other language',
|
|
506
513
|
'[Español](README.es.md)' in content and '[English](README.md)' in content)
|
|
507
514
|
afirma(f'· {filename} shows the real city identity key',
|
package/bin/test-doctor.py
CHANGED
|
@@ -103,4 +103,37 @@ configs_malformadas()
|
|
|
103
103
|
idempotencia()
|
|
104
104
|
fichero()
|
|
105
105
|
preserva_permisos()
|
|
106
|
+
|
|
107
|
+
def revision_del_entorno():
|
|
108
|
+
"""The report a person means when they type `doctor`.
|
|
109
|
+
|
|
110
|
+
Not "is my config an old shape" — that is one line of it. Whether this
|
|
111
|
+
machine can run a city at all, and if not, which part is missing.
|
|
112
|
+
"""
|
|
113
|
+
print(" the machine, not just the config")
|
|
114
|
+
filas = doctor.revisa_entorno()
|
|
115
|
+
areas = {a for a, _, _ in filas}
|
|
116
|
+
for esperada in ("python", "tmux", "node", "runtimes", "cage", "city", "hall bundle"):
|
|
117
|
+
afirma(f"· it checks {esperada}", esperada in areas, str(sorted(areas)))
|
|
118
|
+
afirma("· every row carries a verdict and a reason, never a bare tick",
|
|
119
|
+
all(isinstance(d, str) and d for _, _, d in filas), str(filas))
|
|
120
|
+
# The cage row must explain ITSELF: "no cage" without a reason is the
|
|
121
|
+
# answer that leaves somebody running uncaged and none the wiser.
|
|
122
|
+
fila = next(f for f in filas if f[0] == "cage")
|
|
123
|
+
afirma("· and the cage row says which mechanism, or why there is none",
|
|
124
|
+
any(p in fila[2] for p in ("seatbelt", "bubblewrap", "not installed",
|
|
125
|
+
"refuses", "no cage")), str(fila))
|
|
126
|
+
import io
|
|
127
|
+
from contextlib import redirect_stdout
|
|
128
|
+
|
|
129
|
+
salida = io.StringIO()
|
|
130
|
+
with redirect_stdout(salida):
|
|
131
|
+
codigo = doctor.informe_entorno()
|
|
132
|
+
texto = salida.getvalue()
|
|
133
|
+
afirma("· the printed report names the version it is running",
|
|
134
|
+
"version" in texto and codigo in (0, 1), texto[-300:])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
revision_del_entorno()
|
|
106
139
|
sys.exit(resumen("doctor"))
|