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.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +310 -70
  3. package/README.md +297 -69
  4. package/bin/agents-city.js +3 -0
  5. package/bin/doctor +3 -0
  6. package/bin/hall.html +164 -24
  7. package/bin/navegador.mjs +415 -0
  8. package/bin/serve.py +383 -127
  9. package/bin/shortcut +3 -0
  10. package/bin/test +5 -2
  11. package/bin/test-actualiza.py +130 -0
  12. package/bin/test-atajos.py +301 -0
  13. package/bin/test-busca.py +216 -0
  14. package/bin/test-cage.py +170 -2
  15. package/bin/test-card.py +2 -2
  16. package/bin/test-cities.py +45 -0
  17. package/bin/test-contracts.py +12 -5
  18. package/bin/test-doctor.py +33 -0
  19. package/bin/test-navegador.py +164 -0
  20. package/bin/test-seat.py +245 -25
  21. package/bin/test-serve.py +214 -9
  22. package/bin/test-workspace.py +63 -0
  23. package/bin/testlib.py +23 -0
  24. package/bin/update +3 -0
  25. package/city/web/dist/city.js +47 -47
  26. package/city/web/dist/index.html +1 -1
  27. package/city/web/dist-hall/hall.js +2193 -174
  28. package/city/web/src/bienvenida.ts +686 -0
  29. package/city/web/src/es.ts +180 -0
  30. package/city/web/src/hall.ts +520 -168
  31. package/city/web/src/idioma.ts +86 -0
  32. package/city/web/src/main.ts +27 -0
  33. package/city/web/src/motores.ts +54 -0
  34. package/docs/agents-first.md +8 -1
  35. package/docs/security.md +46 -12
  36. package/docs/testing.md +1 -1
  37. package/package.json +1 -1
  38. package/plugin/.claude-plugin/plugin.json +1 -1
  39. package/plugin/channel/bus.js +1 -1
  40. package/plugin/channel/bus.ts +1 -1
  41. package/plugin/channel/runtime/codex.ts +1 -1
  42. package/plugin/channel/runtime-gateway.js +1 -1
  43. package/plugin/scripts/actualiza.py +198 -0
  44. package/plugin/scripts/atajos.py +506 -0
  45. package/plugin/scripts/busca.py +436 -0
  46. package/plugin/scripts/cage.py +266 -26
  47. package/plugin/scripts/capabilities.py +17 -10
  48. package/plugin/scripts/card.py +10 -0
  49. package/plugin/scripts/cities.py +34 -0
  50. package/plugin/scripts/city-session.sh +33 -7
  51. package/plugin/scripts/doctor.py +122 -0
  52. package/plugin/scripts/find-repos.sh +12 -105
  53. package/plugin/scripts/read-card.py +6 -2
  54. package/plugin/scripts/report.py +5 -6
  55. package/plugin/scripts/reset.py +50 -14
  56. package/plugin/scripts/seat.py +445 -103
  57. 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
- base = tempfile.mkdtemp(prefix="agents-city-cage-")
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', ['api'], meta)
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)
@@ -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
 
@@ -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
- afirma(f'· {filename} tracks the package release used by its tarball examples',
503
- package['version'] in content,
504
- f"README has no {package['version']}")
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',
@@ -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"))