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
package/plugin/scripts/cage.py
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""The cage: one
|
|
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 = (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
137
|
-
#
|
|
138
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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":
|
|
210
|
-
CITY_CAGE=0. The caller prepends the result verbatim, so
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
except
|
|
94
|
+
fuera = dict(busca.repos())
|
|
95
|
+
except OSError:
|
|
85
96
|
return {}
|
|
86
|
-
|
|
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
|
|
package/plugin/scripts/card.py
CHANGED
|
@@ -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):
|
package/plugin/scripts/cities.py
CHANGED
|
@@ -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
|
|
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
|
|
472
|
-
# applies its own sandbox and macOS rejects that nested
|
|
473
|
-
#
|
|
474
|
-
#
|
|
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
|