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
@@ -1,5 +1,9 @@
1
1
  #!/usr/bin/env python3
2
- """The cage: one macOS seatbelt profile per repo window.
2
+ """The cage: one kernel-enforced confinement per agent window.
3
+
4
+ macOS gets a generated seatbelt profile; Linux gets a bubblewrap mount
5
+ namespace. Two mechanisms, one meaning — and the meaning is what this module
6
+ owns, so the launcher asks for a prefix and never learns which kernel it is on.
3
7
 
4
8
  The yolo flag stays — a committee cannot work if every bus command needs a
5
9
  human. What changes is what the kernel lets the window touch. An agent inside
@@ -22,9 +26,12 @@ compromised window can steal — it is not hostile-process isolation.
22
26
  """
23
27
 
24
28
  import argparse
29
+ import functools
25
30
  import os
26
31
  import re
32
+ import shlex
27
33
  import shutil
34
+ import subprocess
28
35
  import sys
29
36
 
30
37
  import rutas
@@ -40,7 +47,15 @@ SECRETOS_DIR = (
40
47
  # it breaks every `npm install` for owners who configure a registry, and a
41
48
  # broken product protects nobody. Owners who keep tokens there add it through
42
49
  # CITY_CAGE_DENY.
43
- SECRETOS_FICHERO = ('.git-credentials', '.netrc', '.pgpass')
50
+ SECRETOS_FICHERO = (
51
+ '.git-credentials', '.netrc', '.pgpass',
52
+ # The one this product created itself. Outside macOS there is no Keychain
53
+ # and Claude Code writes its OAuth access and refresh tokens here as plain
54
+ # JSON — inside a directory the cage keeps WRITABLE for runtime state. Every
55
+ # third-party credential store was sealed and ours was not, which made the
56
+ # cage's promise false exactly where it mattered most.
57
+ os.path.join('.claude', '.credentials.json'),
58
+ )
44
59
 
45
60
  # Writable beyond the repo: the runtime state and build caches a working agent
46
61
  # legitimately owns. Everything here is cache or agent state, never a secret —
@@ -49,12 +64,44 @@ ESCRITURA_CASA = (
49
64
  '.claude', '.agents-city', '.codex', '.opencode', '.kimi',
50
65
  '.npm', '.cache', '.cargo', '.rustup', '.gradle', '.m2',
51
66
  '.pnpm-store', '.bun',
67
+ )
68
+ #: Where each kernel keeps the rest of that state. Splitting the system paths
69
+ #: and leaving these shared was an oversight with teeth: `Library/pnpm` is
70
+ #: pnpm's macOS home and its Linux twin `~/.local/share/pnpm` was missing, so a
71
+ #: caged `pnpm add -g`, `pip install --user`, `pipx` or `go install` all failed
72
+ #: with a read-only filesystem for reasons nobody would connect to the cage.
73
+ ESCRITURA_CASA_MAC = (
52
74
  os.path.join('Library', 'Caches'),
53
75
  os.path.join('Library', 'Logs'),
54
76
  os.path.join('Library', 'pnpm'),
55
77
  os.path.join('Library', 'Developer'),
56
78
  )
79
+ ESCRITURA_CASA_LINUX = (
80
+ os.path.join('.local', 'share'),
81
+ os.path.join('.local', 'state'),
82
+ os.path.join('.local', 'bin'),
83
+ '.config',
84
+ 'go',
85
+ )
86
+ #: Temporary and device paths a working agent legitimately writes, per kernel.
87
+ #: Keyed by platform because "where /tmp really is" is the one fact the two
88
+ #: cages genuinely disagree on — everything else below is shared.
57
89
  ESCRITURA_SISTEMA = ('/dev', '/private/tmp', '/private/var/tmp', '/private/var/folders')
90
+ #: `/dev` is deliberately absent: the argv already gives the sandbox its own
91
+ #: device tree, and re-binding the host's over it would undo that.
92
+ ESCRITURA_SISTEMA_LINUX = ('/tmp', '/var/tmp', '/run/user')
93
+
94
+
95
+ def escritura_sistema():
96
+ """The temp and device paths of the kernel we are RUNNING on.
97
+
98
+ Only for callers that have no cage of their own to name. Each renderer
99
+ passes its own tuple instead: the bubblewrap argv is the Linux cage
100
+ wherever it is built, so a macOS machine writing one — a test, a review —
101
+ must still produce Linux paths, or the thing under test is not the thing
102
+ that ships.
103
+ """
104
+ return ESCRITURA_SISTEMA_LINUX if sys.platform.startswith('linux') else ESCRITURA_SISTEMA
58
105
 
59
106
 
60
107
  def _sbpl(ruta):
@@ -73,6 +120,34 @@ def agents_home(casa):
73
120
  return rutas.canonicaliza(hogar)
74
121
 
75
122
 
123
+ def sellados_por_tipo(casa):
124
+ """The sealed roots, each with what it IS: a file or a directory.
125
+
126
+ Decided by DECLARATION first and by the disk second. It matters because the
127
+ two cages render the two kinds differently — a directory becomes an empty
128
+ tmpfs, a file becomes /dev/null — and a credential file that does not exist
129
+ yet must still be sealed as a file, or the cage mounts a directory in its
130
+ place and the runtime can never create it.
131
+ """
132
+ ac = agents_home(casa)
133
+ fuera = {}
134
+ for d in SECRETOS_DIR:
135
+ fuera[rutas.canonicaliza(os.path.join(casa, d))] = 'dir'
136
+ for f in SECRETOS_FICHERO:
137
+ fuera[rutas.canonicaliza(os.path.join(casa, f))] = 'file'
138
+ fuera[rutas.canonicaliza(os.path.join(ac, '.runtime', 'broker'))] = 'dir'
139
+ for f in ('credentials', 'credentials.toml'):
140
+ fuera[rutas.canonicaliza(os.path.join(casa, '.cargo', f))] = 'file'
141
+ # An owner's own denies: nothing declares their kind, so ask the disk and
142
+ # treat anything else as a directory, which seals more rather than less.
143
+ for p in os.environ.get('CITY_CAGE_DENY', '').split(':'):
144
+ if not p:
145
+ continue
146
+ real = rutas.canonicaliza(os.path.expanduser(p))
147
+ fuera[real] = 'file' if os.path.isfile(real) else 'dir'
148
+ return fuera
149
+
150
+
76
151
  def sellados(casa):
77
152
  """The canonical set of read+write-sealed roots.
78
153
 
@@ -81,18 +156,10 @@ def sellados(casa):
81
156
  it. Extra owner denies (CITY_CAGE_DENY) join it, so an owner can widen the
82
157
  seal but the allow-list can never outrun it.
83
158
  """
84
- ac = agents_home(casa)
85
- extra = [p for p in os.environ.get('CITY_CAGE_DENY', '').split(':') if p]
86
- raices = [os.path.join(casa, d) for d in SECRETOS_DIR]
87
- raices += [os.path.join(casa, f) for f in SECRETOS_FICHERO]
88
- raices += [os.path.expanduser(p) for p in extra]
89
- raices.append(os.path.join(ac, '.runtime', 'broker'))
90
- raices.append(os.path.join(casa, '.cargo', 'credentials'))
91
- raices.append(os.path.join(casa, '.cargo', 'credentials.toml'))
92
- return [rutas.canonicaliza(r) for r in raices]
159
+ return list(sellados_por_tipo(casa))
93
160
 
94
161
 
95
- def _permitidas_escritura(repo, casa, bloqueados, extra_escritura=()):
162
+ def _permitidas_escritura(repo, casa, bloqueados, extra_escritura=(), sistema=None):
96
163
  """Canonical write-allow roots, given the already-computed sealed set.
97
164
 
98
165
  Covering roots are intentionally kept: the sealed-secret block is emitted
@@ -111,8 +178,12 @@ def _permitidas_escritura(repo, casa, bloqueados, extra_escritura=()):
111
178
  extra = [os.path.expanduser(p)
112
179
  for p in os.environ.get('CITY_CAGE_ALLOW_WRITE', '').split(':') if p]
113
180
  extra += list(extra_escritura)
114
- fijas = [repo, agents_home(casa), *ESCRITURA_SISTEMA]
181
+ linux = sistema is ESCRITURA_SISTEMA_LINUX or (
182
+ sistema is None and sys.platform.startswith('linux'))
183
+ fijas = [repo, agents_home(casa), *(sistema if sistema is not None else escritura_sistema())]
115
184
  fijas += [os.path.join(casa, p) for p in ESCRITURA_CASA]
185
+ fijas += [os.path.join(casa, p)
186
+ for p in (ESCRITURA_CASA_LINUX if linux else ESCRITURA_CASA_MAC)]
116
187
  aceptadas = [rutas.canonicaliza(c) for c in fijas]
117
188
  for c in extra:
118
189
  if any(rutas.dentro_de(c, raiz) for raiz in bloqueados):
@@ -123,25 +194,54 @@ def _permitidas_escritura(repo, casa, bloqueados, extra_escritura=()):
123
194
 
124
195
 
125
196
  def _lineas_permite_escritura(repo, casa, bloqueados, extra_escritura=()):
126
- permitidas = _permitidas_escritura(repo, casa, bloqueados, extra_escritura)
197
+ permitidas = _permitidas_escritura(repo, casa, bloqueados, extra_escritura,
198
+ sistema=ESCRITURA_SISTEMA)
127
199
  lineas = [f' (subpath {_sbpl(r)})' for r in permitidas]
128
200
  # Claude keeps `~/.claude.json` (and its backups) at the HOME root.
129
201
  lineas.append(f' (regex #"^{re.escape(os.path.join(casa, ".claude.json"))}")')
130
202
  return lineas
131
203
 
132
204
 
205
+ def carreteras_raiz(casa):
206
+ """Where a seat's remote-road channels live. One spelling, two readers."""
207
+ return os.path.join(casa, '.claude', 'channels')
208
+
209
+
210
+ def env_de_carreteras(casa):
211
+ """The remote-road token files: `~/.claude/channels/<seat>/.env`.
212
+
213
+ One definition of "these are secrets too", because the two cages render it
214
+ differently — macOS as a regex over a path that may not exist yet, Linux as
215
+ one mount per file that does. A rule written twice is a rule that will be
216
+ sealed on one kernel and open on the other.
217
+ """
218
+ canales = carreteras_raiz(casa)
219
+ try:
220
+ entradas = sorted(os.listdir(canales))
221
+ except OSError:
222
+ return []
223
+ fuera = []
224
+ for entrada in entradas:
225
+ env = os.path.join(canales, entrada, '.env')
226
+ if os.path.isfile(env):
227
+ fuera.append(env)
228
+ return fuera
229
+
230
+
133
231
  def _lineas_secretos(casa, bloqueados):
232
+ tipos = sellados_por_tipo(casa)
134
233
  lineas = []
135
234
  for raiz in bloqueados:
136
- # A file target uses `literal`; a directory (or a not-yet-created dir)
137
- # uses `subpath` so everything beneath it is sealed too.
138
- if os.path.isfile(raiz):
235
+ # A file target uses `literal`; a directory uses `subpath` so everything
236
+ # beneath it is sealed too. The kind comes from the declaration, so a
237
+ # credential file that does not exist yet is still sealed as a file.
238
+ if tipos.get(raiz, 'dir') == 'file':
139
239
  lineas.append(f' (literal {_sbpl(raiz)})')
140
240
  else:
141
241
  lineas.append(f' (subpath {_sbpl(raiz)})')
142
242
  # Remote road tokens configured for the seat: repo windows must never read
143
243
  # them, even though the rest of `~/.claude` stays open for the runtime.
144
- canal = re.escape(os.path.join(casa, '.claude', 'channels'))
244
+ canal = re.escape(carreteras_raiz(casa))
145
245
  lineas.append(f' (regex #"^{canal}/[^/]+/\\.env$")')
146
246
  return lineas
147
247
 
@@ -181,11 +281,146 @@ def perfil(repo, casa=None, fichero_token=None, extra_escritura=()):
181
281
  return '\n'.join(partes) + '\n'
182
282
 
183
283
 
284
+ # ── Linux: the same seal, built out of mounts instead of a profile ───────────
285
+ #
286
+ # Bubblewrap applies its binds in order and the last one wins, so the argv obeys
287
+ # the same ordering guarantee the profile above states — and a sealed path is
288
+ # not refused here, it is simply not mounted.
289
+
290
+ #: The base every caged launch starts from — and the exact same flags the
291
+ #: availability probe runs, so "bwrap works here" cannot mean a different
292
+ #: sandbox than the one an agent gets.
293
+ BASE_BWRAP = ['bwrap', '--ro-bind', '/', '/', '--dev', '/dev']
294
+
295
+
296
+ @functools.lru_cache(maxsize=None)
297
+ def _prueba_bwrap():
298
+ """Build one real namespace and remember whether it worked."""
299
+ if not shutil.which('bwrap'):
300
+ return False
301
+ try:
302
+ hecho = subprocess.run(
303
+ # It either builds a namespace in milliseconds or it is refused;
304
+ # nothing in between, so a long timeout only buys dead launch time.
305
+ [*BASE_BWRAP, 'true'],
306
+ capture_output=True,
307
+ timeout=5,
308
+ )
309
+ return hecho.returncode == 0
310
+ except (OSError, subprocess.SubprocessError):
311
+ return False
312
+
313
+
314
+ def bwrap_sirve():
315
+ """Whether bubblewrap can actually build a namespace here — not merely
316
+ whether the binary exists.
317
+
318
+ Ubuntu 24 restricts unprivileged user namespaces through AppArmor, and a
319
+ hardened kernel can refuse them outright. A prefix that fails at launch
320
+ would take the agent's window down with it, so this asks bwrap to do the
321
+ real thing rather than trusting `which`.
322
+
323
+ The launcher runs this module as a fresh process PER WINDOW, so the answer
324
+ is also read from and published to the environment: a city of eight agents
325
+ forks one probe, not eight. `CITY_CAGE_BWRAP=1|0` is that channel, and an
326
+ owner can set it by hand to skip the probe entirely.
327
+ """
328
+ # The environment outranks the memo on purpose: it is how the launcher hands
329
+ # one probe's answer to every window, and how an owner skips probing at all.
330
+ dicho = os.environ.get('CITY_CAGE_BWRAP')
331
+ if dicho == '0':
332
+ return False
333
+ if dicho == '1':
334
+ # Trusted, but not blindly: tmux windows inherit the server's whole
335
+ # environment, so a value set on another machine (or a stale one) would
336
+ # otherwise build a prefix that exits 127 on every single window.
337
+ return shutil.which('bwrap') is not None
338
+ return _prueba_bwrap()
339
+
340
+
341
+ def argv_bwrap(repo, casa=None, fichero_token=None, extra_escritura=()):
342
+ """The bubblewrap argv that cages one window on Linux. Pure: no writes.
343
+
344
+ Returns the prefix only — the caller appends the command to run.
345
+ """
346
+ casa = rutas.canonicaliza(casa or '~')
347
+ repo = rutas.canonicaliza(repo)
348
+ if not os.path.isdir(repo):
349
+ raise ValueError(f'the cage needs an existing working directory, got: {repo}')
350
+ bloqueados = sellados(casa)
351
+ motivo = rutas.motivo_bloqueo(repo, bloqueados)
352
+ if motivo:
353
+ raise ValueError(f'the working directory is unsafe to cage: {motivo}')
354
+
355
+ # Deliberately NOT here:
356
+ # --proc needs a PID namespace to be worth anything, and a PID
357
+ # namespace would make the runtime gateway write a
358
+ # namespace-local pid into <runtime>/gateways/<actor>.pid —
359
+ # which `agents-city exit` then signals on the host, killing
360
+ # an unrelated process. /proc arrives with the read-only bind
361
+ # of / anyway.
362
+ # --die-with-parent
363
+ # the bus hub is started detached ON PURPOSE so it outlives
364
+ # the window that happened to start it; tying the namespace's
365
+ # life to one pane would take the whole city's bus down with
366
+ # that pane. macOS has no equivalent flag either.
367
+ argv = BASE_BWRAP.copy()
368
+ # The working set, re-bound writable over the read-only world. The set is
369
+ # `_permitidas_escritura`'s answer and only that: the same list the SBPL
370
+ # profile allows, so the two cages cannot drift into permitting different
371
+ # things.
372
+ for ruta in _permitidas_escritura(repo, casa, bloqueados, extra_escritura,
373
+ sistema=ESCRITURA_SISTEMA_LINUX):
374
+ # `-try` because a build cache nobody has created yet is not an error,
375
+ # and a cage that refuses to start is a cage nobody keeps switched on.
376
+ argv += ['--bind-try', ruta, ruta]
377
+ # And then the seal. A directory becomes an empty tmpfs; a file becomes
378
+ # /dev/null, which cannot be read through.
379
+ #
380
+ # ONLY what exists is sealed, and that is not a shortcut — it is the
381
+ # difference between a cage and a machine that cannot start. `--tmpfs` has
382
+ # no `-try` form and bwrap creates the mountpoint with a single mkdir: over
383
+ # a read-only `/`, sealing an absent `~/.aws` aborts the launch, and where
384
+ # the parent IS writable it does worse — it leaves a real empty directory
385
+ # on the owner's disk, so a later `cargo login` fails forever with "Is a
386
+ # directory" and nothing points back here.
387
+ #
388
+ # Skipping an absent path costs nothing: `$HOME` itself is never writable
389
+ # inside the cage, so a window cannot create the secret it was not sealed
390
+ # from. What a seal cannot cover is a path created OUTSIDE, mid-session —
391
+ # the same bounded difference the road tokens have, documented in
392
+ # docs/security.md rather than papered over.
393
+ tipos = sellados_por_tipo(casa)
394
+ for raiz in bloqueados:
395
+ if not os.path.exists(raiz):
396
+ continue
397
+ if tipos.get(raiz, 'dir') == 'file':
398
+ argv += ['--ro-bind-try', '/dev/null', raiz]
399
+ else:
400
+ argv += ['--tmpfs', raiz]
401
+ # Claude keeps `~/.claude.json` at the HOME root, and the SBPL profile
402
+ # allows writing it. Without the same line here the Linux cage silently
403
+ # breaks the runtime it is meant to protect.
404
+ conf = rutas.canonicaliza(os.path.join(casa, '.claude.json'))
405
+ argv += ['--bind-try', conf, conf]
406
+ # Remote road tokens: the same rule the profile states as a regex, applied
407
+ # here one file at a time because a mount needs a path, not a pattern.
408
+ for env in env_de_carreteras(casa):
409
+ argv += ['--ro-bind-try', '/dev/null', env]
410
+ if fichero_token:
411
+ # Last word: this window's own token, and only this one.
412
+ real = rutas.canonicaliza(fichero_token)
413
+ argv += ['--ro-bind-try', real, real]
414
+ return argv
415
+
416
+
184
417
  def disponible():
185
418
  """Whether this machine can cage a window at all."""
186
419
  if os.environ.get('CITY_CAGE', '1') == '0':
187
420
  return False
188
- return sys.platform == 'darwin' and shutil.which('sandbox-exec') is not None
421
+ if sys.platform == 'darwin':
422
+ return shutil.which('sandbox-exec') is not None
423
+ return sys.platform.startswith('linux') and bwrap_sirve()
189
424
 
190
425
 
191
426
  def escribe_perfil(repo, ventana, casa=None, fichero_token=None, extra_escritura=()):
@@ -204,17 +439,22 @@ def escribe_perfil(repo, ventana, casa=None, fichero_token=None, extra_escritura
204
439
 
205
440
 
206
441
  def linea(repo, ventana, casa=None, fichero_token=None, extra_escritura=()):
207
- """The launch prefix for one window: `sandbox-exec -f <profile> `, or ''.
442
+ """The launch prefix for one window, ready to prepend to a shell command:
443
+ `sandbox-exec -f <profile> ` on macOS, `bwrap … ` on Linux, or ''.
208
444
 
209
- Empty means "launch uncaged": not macOS, no sandbox-exec, or the owner set
210
- CITY_CAGE=0. The caller prepends the result verbatim, so the degraded path
211
- is exactly the behaviour the product always had.
445
+ Empty means "launch uncaged": an unsupported platform, no sandboxing tool,
446
+ or the owner set CITY_CAGE=0. The caller prepends the result verbatim, so
447
+ the degraded path is exactly the behaviour the product always had.
212
448
  """
213
449
  if not disponible():
214
450
  return ''
215
- ruta = escribe_perfil(repo, ventana, casa=casa, fichero_token=fichero_token,
216
- extra_escritura=extra_escritura)
217
- return f'sandbox-exec -f {ruta} '
451
+ if sys.platform == 'darwin':
452
+ ruta = escribe_perfil(repo, ventana, casa=casa, fichero_token=fichero_token,
453
+ extra_escritura=extra_escritura)
454
+ return f'sandbox-exec -f {ruta} '
455
+ argv = argv_bwrap(repo, casa=casa, fichero_token=fichero_token,
456
+ extra_escritura=extra_escritura)
457
+ return shlex.join(argv) + ' '
218
458
 
219
459
 
220
460
  def main():
@@ -9,9 +9,9 @@ can see which agent is likely to know how to help.
9
9
 
10
10
  import os
11
11
  import re
12
- import subprocess
13
12
  import sys
14
13
 
14
+ import busca # the one disk scanner: repos, worktrees and document folders
15
15
  import card
16
16
  import cities
17
17
  import workspace
@@ -77,17 +77,24 @@ def descubre_repo(ruta_repo):
77
77
  return fuera
78
78
 
79
79
 
80
- def _indice_repos():
81
- guion = os.path.join(os.path.dirname(__file__), "find-repos.sh")
80
+ #: The disk index, remembered for a moment. `busca` keeps its own
81
+ #: day-long cache, but SPAWNING it and re-parsing the TSV is ~15 ms, and one
82
+ #: Hall request resolves it once per legacy agent — N+1 processes for one
83
+ #: answer. A short memory collapses that to one while staying young enough to
84
+ #: notice a repo cloned a minute ago.
85
+ _INDICE = {"cuando": 0.0, "valor": None}
86
+
87
+
88
+ def _indice_repos(vida=90):
89
+ import time
90
+
91
+ if _INDICE["valor"] is not None and time.monotonic() - _INDICE["cuando"] < vida:
92
+ return _INDICE["valor"]
82
93
  try:
83
- salida = subprocess.run([guion], capture_output=True, text=True, timeout=300).stdout
84
- except (OSError, subprocess.TimeoutExpired):
94
+ fuera = dict(busca.repos())
95
+ except OSError:
85
96
  return {}
86
- fuera = {}
87
- for linea in salida.splitlines():
88
- if "\t" in linea:
89
- nombre, ruta = linea.split("\t", 1)
90
- fuera[nombre.strip()] = ruta.strip()
97
+ _INDICE["cuando"], _INDICE["valor"] = time.monotonic(), fuera
91
98
  return fuera
92
99
 
93
100
 
@@ -27,6 +27,16 @@ import re
27
27
  # than that something moved.
28
28
  CAMPOS = ('user', 'name', 'role', 'agent', 'repos', 'goals_defined')
29
29
  ROLE_ID = re.compile(r'^[a-z0-9][a-z0-9-]{0,63}$')
30
+ #: What `ventana()` can produce, as a shape callers can check without rebuilding
31
+ #: it. A window slug is longer than a role id (80 vs 64), and every door that
32
+ #: resolves an agent by slug has to agree on that or a long-named agent renders
33
+ #: everywhere and answers nowhere.
34
+ VENTANA_ID = re.compile(r'^[a-z0-9][a-z0-9-]{0,79}$')
35
+
36
+
37
+ def ventana_valida(valor):
38
+ """Whether this is a window slug the rest of the product will accept."""
39
+ return bool(VENTANA_ID.fullmatch(str(valor or '')))
30
40
 
31
41
 
32
42
  def frontmatter(texto):
@@ -248,6 +248,40 @@ def crea(usuario, nombre_ciudad, usar=True):
248
248
  return _real(destino)
249
249
 
250
250
 
251
+ def archiva(datos, usuario=''):
252
+ """Take one city out of use, keeping every byte of it.
253
+
254
+ Deliberately NOT a delete. A city is somebody's cards, deliberations and
255
+ map; a product that offers to erase that with one click will eventually
256
+ erase the wrong one. So the folder MOVES into `<user>/.backups/` with a
257
+ timestamp, the registry forgets it, and the selection falls back to another
258
+ city — recoverable with `mv`, which is a sentence a person can act on.
259
+
260
+ Returns the backup path. Refuses a city that is not this owner's managed
261
+ one, and refuses the last one standing: a city list with nothing in it is
262
+ not a state this product knows how to be in.
263
+ """
264
+ usuario = _slug(usuario or usuario_actual(), 'me')
265
+ real = _real(datos)
266
+ if not es_ciudad(real):
267
+ raise ValueError(f'{datos} is not a city')
268
+ if not gestionada(real, usuario):
269
+ raise ValueError('that city lives outside this owner\'s folder; move it by hand')
270
+ restantes = [c for c in lista(usuario) if _real(c['ruta']) != real]
271
+ if not restantes:
272
+ raise ValueError('this is your only city — create another one first')
273
+ base = os.path.join(carpeta_usuario(usuario), '.backups')
274
+ os.makedirs(base, mode=0o700, exist_ok=True)
275
+ destino, _ = _respaldo_libre(base, f'archivada-{nombre(real) or "city"}')
276
+ # The registry only ever held EXTERNAL cities — a managed one is found by
277
+ # walking the owner's folder, so moving it out is the whole removal.
278
+ seleccionada = _real(actual(usuario, crear=False) or '')
279
+ shutil.move(real, destino)
280
+ if seleccionada == real:
281
+ selecciona(usuario, restantes[0]['ruta'])
282
+ return destino
283
+
284
+
251
285
  def _registro_para(usuario):
252
286
  try:
253
287
  lineas = [l.strip() for l in open(REGISTRO, encoding='utf-8') if l.strip()]
@@ -160,11 +160,34 @@ CLAUDE_AUTH_PREFIX=""
160
160
  CAGE="$(dirname "$0")/cage.py"
161
161
  BROKERPY="$(dirname "$0")/broker.py"
162
162
  WORKSPACE="$(dirname "$0")/workspace.py"
163
+ # On Linux the cage is a bubblewrap namespace, and deciding whether the kernel
164
+ # will grant one means actually building one. `cage.py` runs as a fresh process
165
+ # per window, so without this the probe is paid once per agent instead of once
166
+ # per city — and on a kernel that refuses namespaces, paid slowly. Ask once,
167
+ # hand the answer to every window.
168
+ if [ -z "${CITY_CAGE_BWRAP:-}" ] && [ "$(uname -s)" = "Linux" ]; then
169
+ if python3 "$CAGE" check >/dev/null 2>&1; then
170
+ CITY_CAGE_BWRAP=1
171
+ else
172
+ CITY_CAGE_BWRAP=0
173
+ fi
174
+ export CITY_CAGE_BWRAP
175
+ fi
176
+
163
177
  jaula_de() { # window, cwd, [broker token file], [colon-joined mount targets] -> prefix or ''
164
- local args=(line --window "$1" --repo "$2")
178
+ local args=(line --window "$1" --repo "$2") salida estado
165
179
  [ -n "${3:-}" ] && args+=(--token-file "$3")
166
180
  [ -n "${4:-}" ] && args+=(--mounts "$4")
167
- python3 "$CAGE" "${args[@]}" 2>/dev/null || true
181
+ salida="$(python3 "$CAGE" "${args[@]}" 2>&1)"; estado=$?
182
+ # An empty prefix is a normal answer: no cage on this machine, or CITY_CAGE=0.
183
+ # A FAILURE is not — a working directory that covers a sealed root is refused
184
+ # on purpose, and swallowing that refusal launched the window uncaged and
185
+ # silent, which is the one outcome nobody would notice.
186
+ if [ $estado -ne 0 ]; then
187
+ printf ' %s launches WITHOUT a cage: %s\n' "$1" "$salida" >&2
188
+ return 0
189
+ fi
190
+ printf '%s' "$salida"
168
191
  }
169
192
 
170
193
  # Which model and effort a window's agent starts with. Three voices, in order:
@@ -468,13 +491,16 @@ for path in ${RUTAS[@]+"${RUTAS[@]}"}; do
468
491
  "$espera$(sync_line)AGENTS_CITY_DATA=$EQUIPO AGENTS_CITY_HOME=${AGENTS_CITY_HOME:-$HOME/.agents-city} AGENTS_CITY_USER=$USUARIO CITY_ADDRESS=$ADDRESS CITY_SEAT_NAME=$SEAT_NAME CITY_BUS_ACTOR=$win CITY_AGENT_ROLE=$ROL_REPO CITY_RUNTIME_KIND=repo CITY_BUS_URL= CITY_BUS_TOKEN= $BROKER_ENV$JAULA$CAGE_RUNTIME_ENV${CLAUDE_AUTH_PREFIX}$(gateway_line "$win" "$path" "$CLAUDE_REPO")"
469
492
  elif [ "$KIND" = codex ] || [ "$KIND" = opencode ] || [ "$KIND" = kimi ]; then
470
493
  # Native servers have their own credentials and do not share Claude's OAuth race.
471
- # Codex must remain outside the outer macOS seatbelt: its node_repl MCP
472
- # applies its own sandbox and macOS rejects that nested sandbox_apply.
473
- # Codex's app-server receives workspace-write below; OpenCode and Kimi
474
- # still use the per-repo outer cage.
494
+ # Codex stays outside the outer cage ON macOS ONLY: its node_repl MCP
495
+ # applies its own sandbox and the macOS kernel rejects that nested
496
+ # sandbox_apply. That is a seatbelt constraint, not a fact about Codex —
497
+ # bubblewrap nests fine, and an unconditional exemption meant a Codex
498
+ # window on Linux could read ~/.ssh outright while every other window in
499
+ # the same city had it sealed. Codex's app-server still receives
500
+ # workspace-write below, which bounds writes but never reads.
475
501
  RUNTIME_CAGE="$JAULA"
476
502
  RUNTIME_CAGE_ENV="$CAGE_RUNTIME_ENV"
477
- if [ "$KIND" = codex ]; then
503
+ if [ "$KIND" = codex ] && [ "$(uname -s)" = "Darwin" ]; then
478
504
  RUNTIME_CAGE=""
479
505
  RUNTIME_CAGE_ENV=""
480
506
  fi