agents-city 0.5.2 → 0.5.3
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/bin/serve.py +18 -1
- package/bin/test-arnes.py +109 -0
- package/bin/test-channel.py +34 -2
- package/bin/test-seat.py +52 -0
- package/bin/test-serve.py +24 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/scripts/arnes.py +10 -1
package/bin/serve.py
CHANGED
|
@@ -619,6 +619,23 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
619
619
|
_ultimo = None
|
|
620
620
|
_error = None
|
|
621
621
|
|
|
622
|
+
def _ciudad_para_apuntar(self, q):
|
|
623
|
+
"""The same city `ciudad` would resolve, resolved without touching it.
|
|
624
|
+
|
|
625
|
+
`ciudad` calls `asegura_metadata`, which does `makedirs` and writes
|
|
626
|
+
`city.yml` — right when a request is about to act on a city, and wrong
|
|
627
|
+
when the only reason we are asking is to write a log line. The journal
|
|
628
|
+
runs AFTER the handler, so on a request that archived or reset a city it
|
|
629
|
+
recreated the very folder that had just been taken away, leaving a ghost
|
|
630
|
+
behind. Observing something must not create it.
|
|
631
|
+
"""
|
|
632
|
+
pedida = q.get("city", [""])[0]
|
|
633
|
+
if pedida:
|
|
634
|
+
resuelta = self.resuelve_conocida(pedida)
|
|
635
|
+
if resuelta:
|
|
636
|
+
return resuelta
|
|
637
|
+
return seat.donde_viven_las_fichas()
|
|
638
|
+
|
|
622
639
|
def apunta(self, q, tipo, **campos):
|
|
623
640
|
"""One journal line, in the journal of the city the request acted on.
|
|
624
641
|
|
|
@@ -628,7 +645,7 @@ class Manejador(http.server.BaseHTTPRequestHandler):
|
|
|
628
645
|
record, because it is a record that lies about where.
|
|
629
646
|
"""
|
|
630
647
|
try:
|
|
631
|
-
diario.apunta(self.
|
|
648
|
+
diario.apunta(self._ciudad_para_apuntar(q), tipo, **campos)
|
|
632
649
|
except Exception: # noqa: BLE001 logging must not break the request
|
|
633
650
|
pass
|
|
634
651
|
|
package/bin/test-arnes.py
CHANGED
|
@@ -20,6 +20,7 @@ So there are two things to defend here, and the second is the hard one:
|
|
|
20
20
|
import json
|
|
21
21
|
import os
|
|
22
22
|
import re
|
|
23
|
+
import subprocess
|
|
23
24
|
import sys
|
|
24
25
|
import tempfile
|
|
25
26
|
|
|
@@ -119,12 +120,119 @@ def sin_deriva():
|
|
|
119
120
|
arnes.banderas("codex") == "" and arnes.banderas("opencode") == "",
|
|
120
121
|
f"codex={arnes.banderas('codex')!r}")
|
|
121
122
|
|
|
123
|
+
# And it survives a SHELL, which is what receives it.
|
|
124
|
+
#
|
|
125
|
+
# This is the check that was missing, and its absence cost every Claude
|
|
126
|
+
# window in a city. Emitted bare, `--settings {"a":"b","c":true}` is
|
|
127
|
+
# destroyed twice before Claude sees it: brace expansion splits it on the
|
|
128
|
+
# comma, quote removal eats the double quotes, and what arrives is
|
|
129
|
+
# `--settings {a:b}` — "Invalid JSON provided to --settings".
|
|
130
|
+
#
|
|
131
|
+
# Asserting that the string CONTAINS the right words could never have
|
|
132
|
+
# caught that. So this parses the line the way a shell does and reads the
|
|
133
|
+
# value back as JSON, which is the only claim that matters.
|
|
134
|
+
import shlex # noqa: PLC0415
|
|
135
|
+
|
|
136
|
+
palabras = shlex.split(arnes.banderas("claude"))
|
|
137
|
+
afirma("· the flags survive shell parsing as separate words",
|
|
138
|
+
"--settings" in palabras and "--disallowed-tools" in palabras, str(palabras))
|
|
139
|
+
valor = palabras[palabras.index("--settings") + 1]
|
|
140
|
+
try:
|
|
141
|
+
ajustes = json.loads(valor)
|
|
142
|
+
except json.JSONDecodeError as e:
|
|
143
|
+
ajustes = None
|
|
144
|
+
afirma("· and the settings value is still JSON afterwards", False, f"{valor!r}: {e}")
|
|
145
|
+
if ajustes is not None:
|
|
146
|
+
afirma("· and the settings value is still JSON afterwards", True, "")
|
|
147
|
+
comprueba("· with the cross-session path closed",
|
|
148
|
+
ajustes.get("crossSessionInbound"), "refuse")
|
|
149
|
+
afirma("· and every declared settings key inside it",
|
|
150
|
+
all(t["clave"] in ajustes
|
|
151
|
+
for t in arnes.declaracion()["claude"]["trato"]
|
|
152
|
+
if t.get("rinde") == "settings"),
|
|
153
|
+
str(ajustes))
|
|
154
|
+
# A real shell, not just a parser: brace expansion is the half `shlex`
|
|
155
|
+
# forgives, and it is the half that broke.
|
|
156
|
+
import subprocess # noqa: PLC0415
|
|
157
|
+
|
|
158
|
+
r = subprocess.run(
|
|
159
|
+
["bash", "-c", 'set -- ' + arnes.banderas("claude") + '; printf "%s\n" "$@"'],
|
|
160
|
+
capture_output=True, text=True,
|
|
161
|
+
)
|
|
162
|
+
entregado = [l for l in r.stdout.split("\n") if l]
|
|
163
|
+
comprueba("· a real shell hands over exactly four words", len(entregado), 4)
|
|
164
|
+
try:
|
|
165
|
+
json.loads(entregado[1])
|
|
166
|
+
afirma("· and the second is the settings, intact", True, "")
|
|
167
|
+
except json.JSONDecodeError as e:
|
|
168
|
+
afirma("· and the second is the settings, intact", False, f"{entregado!r}: {e}")
|
|
169
|
+
|
|
122
170
|
|
|
123
171
|
def _es_metodo_declarado(aguja, declaradas):
|
|
124
172
|
"""`approvalPolicy` declared, read through a method of the same name."""
|
|
125
173
|
return any(aguja.lower() == d.lower() for d in declaradas)
|
|
126
174
|
|
|
127
175
|
|
|
176
|
+
def valores_que_no_sobrevivirian(tmp):
|
|
177
|
+
"""Values that a shell would eat, or run.
|
|
178
|
+
|
|
179
|
+
The happy checks above prove today's declaration survives. They cannot
|
|
180
|
+
prove the NEXT one will: the values there are mild strings, and the bug
|
|
181
|
+
that broke every Claude window was a comma. The day somebody declares a
|
|
182
|
+
value with a space in it, or a quote, this has to hold — and if it does
|
|
183
|
+
not, it must fail here rather than in a person's terminal.
|
|
184
|
+
|
|
185
|
+
The last two are not a formatting concern. `$(...)` and backticks in an
|
|
186
|
+
unquoted argument are executed by the shell, and this declaration is read
|
|
187
|
+
from a file: a value that runs a command is a value that runs somebody
|
|
188
|
+
else's command.
|
|
189
|
+
"""
|
|
190
|
+
print(" values a shell would eat, or run")
|
|
191
|
+
testigo = os.path.join(tmp, "ejecutado")
|
|
192
|
+
hostiles = {
|
|
193
|
+
"conEspacio": "dos palabras",
|
|
194
|
+
"conComillas": 'dice "hola" y \'adios\'',
|
|
195
|
+
"conLlaves": "{a,b}",
|
|
196
|
+
"conPuntoYComa": "uno; echo dos",
|
|
197
|
+
"conDolar": "$HOME y ${OTRO}",
|
|
198
|
+
"conSustitucion": f"$(touch {testigo})",
|
|
199
|
+
"conAcentoGrave": f"`touch {testigo}`",
|
|
200
|
+
}
|
|
201
|
+
real = arnes.declaracion
|
|
202
|
+
arnes.declaracion = lambda: {
|
|
203
|
+
"claude": {
|
|
204
|
+
"trato": [
|
|
205
|
+
{"clave": k, "valor": v, "via": "x", "porque": "y" * 30, "rinde": "settings"}
|
|
206
|
+
for k, v in hostiles.items()
|
|
207
|
+
]
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
try:
|
|
211
|
+
linea = arnes.banderas("claude")
|
|
212
|
+
finally:
|
|
213
|
+
arnes.declaracion = real
|
|
214
|
+
|
|
215
|
+
# A real shell, because `shlex` forgives what bash does not.
|
|
216
|
+
r = subprocess.run(
|
|
217
|
+
["bash", "-c", "set -- " + linea + '; printf "%s\\n" "$@"'],
|
|
218
|
+
capture_output=True, text=True,
|
|
219
|
+
)
|
|
220
|
+
entregado = [l for l in r.stdout.split("\n") if l]
|
|
221
|
+
afirma("· a hostile declaration still comes out as two words",
|
|
222
|
+
len(entregado) == 2 and entregado[0] == "--settings",
|
|
223
|
+
f"{entregado!r} from {linea!r}")
|
|
224
|
+
afirma("· nothing in it was executed on the way",
|
|
225
|
+
not os.path.exists(testigo),
|
|
226
|
+
f"{testigo} exists: a declared value ran a command")
|
|
227
|
+
try:
|
|
228
|
+
vuelta = json.loads(entregado[1]) if len(entregado) > 1 else {}
|
|
229
|
+
except json.JSONDecodeError as e:
|
|
230
|
+
vuelta = {}
|
|
231
|
+
afirma("· and it is still JSON on the other side", False, f"{entregado[1]!r}: {e}")
|
|
232
|
+
for clave, valor in hostiles.items():
|
|
233
|
+
comprueba(f"· {clave} arrives exactly as declared", vuelta.get(clave), valor)
|
|
234
|
+
|
|
235
|
+
|
|
128
236
|
def lo_que_hay_en_el_disco():
|
|
129
237
|
print(" it reads the machine, and admits what it cannot read")
|
|
130
238
|
casa = tempfile.mkdtemp()
|
|
@@ -183,6 +291,7 @@ def el_informe():
|
|
|
183
291
|
def main():
|
|
184
292
|
la_declaracion()
|
|
185
293
|
sin_deriva()
|
|
294
|
+
valores_que_no_sobrevivirian(tempfile.mkdtemp())
|
|
186
295
|
lo_que_hay_en_el_disco()
|
|
187
296
|
el_informe()
|
|
188
297
|
return resumen("arnes")
|
package/bin/test-channel.py
CHANGED
|
@@ -22,6 +22,37 @@ CHANNEL = os.path.join(RAIZ, 'plugin', 'channel', 'run.sh')
|
|
|
22
22
|
CLIENT = os.path.join(RAIZ, 'plugin', 'channel', 'client.js')
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
def espera_avisos(bus, quieto=0.6, tope=10):
|
|
26
|
+
"""The seat's wake-ups, once they have arrived AND stopped arriving.
|
|
27
|
+
|
|
28
|
+
Reading `bus.mensajes` at a fixed moment samples a race, and a sample lies
|
|
29
|
+
in both directions: it saw zero on a loaded Linux runner and failed a
|
|
30
|
+
working product, and on a slow one it could see two of a hundred and pass a
|
|
31
|
+
broken one. Both halves of the assertion need the burst to be over — the
|
|
32
|
+
lower bound needs them to have arrived, the upper bound needs no more to be
|
|
33
|
+
coming.
|
|
34
|
+
|
|
35
|
+
So: wait for the first, then wait until none has appeared for `quieto`.
|
|
36
|
+
Quiescence, not a guess at how long a hundred arrivals take.
|
|
37
|
+
"""
|
|
38
|
+
def cuantos():
|
|
39
|
+
return sum(1 for m in bus.mensajes
|
|
40
|
+
if m.get('method') == 'notifications/claude/channel')
|
|
41
|
+
|
|
42
|
+
limite = time.monotonic() + tope
|
|
43
|
+
bus.espera(lambda m: m.get('method') == 'notifications/claude/channel', segundos=tope)
|
|
44
|
+
ultimo, estable = cuantos(), time.monotonic()
|
|
45
|
+
while time.monotonic() < limite:
|
|
46
|
+
time.sleep(0.05)
|
|
47
|
+
ahora = cuantos()
|
|
48
|
+
if ahora != ultimo:
|
|
49
|
+
ultimo, estable = ahora, time.monotonic()
|
|
50
|
+
elif time.monotonic() - estable >= quieto:
|
|
51
|
+
break
|
|
52
|
+
return [m for m in bus.mensajes
|
|
53
|
+
if m.get('method') == 'notifications/claude/channel']
|
|
54
|
+
|
|
55
|
+
|
|
25
56
|
def espera(condicion, segundos=8):
|
|
26
57
|
limite = time.monotonic() + segundos
|
|
27
58
|
while time.monotonic() < limite:
|
|
@@ -398,8 +429,9 @@ def main():
|
|
|
398
429
|
'alice/home' in texto(roster)
|
|
399
430
|
and 'alice/ghost' not in texto(roster)
|
|
400
431
|
and '"online": true' in texto(roster), texto(roster))
|
|
401
|
-
|
|
402
|
-
|
|
432
|
+
# Waited for, not sampled. Reading the list at a fixed moment saw zero
|
|
433
|
+
# on a loaded runner and failed a product that was working.
|
|
434
|
+
road_notices = espera_avisos(b)
|
|
403
435
|
# Coalesced, not exactly-one. The property is that a hundred arrivals
|
|
404
436
|
# do not become a hundred interruptions; whether the window happens to
|
|
405
437
|
# close once or twice mid-burst is the machine's business, and asserting
|
package/bin/test-seat.py
CHANGED
|
@@ -1101,6 +1101,56 @@ def motores_del_puesto():
|
|
|
1101
1101
|
)
|
|
1102
1102
|
|
|
1103
1103
|
|
|
1104
|
+
def el_settings_sobrevive_a_la_shell(asiento, api):
|
|
1105
|
+
"""The launch line, parsed the way a shell parses it.
|
|
1106
|
+
|
|
1107
|
+
Asserting the words appear somewhere in the string is not enough, and the
|
|
1108
|
+
gap cost every Claude window in a city: `--settings {"a":"b","c":true}`
|
|
1109
|
+
emitted without quotes is split by brace expansion on the comma and
|
|
1110
|
+
stripped of its double quotes, so what reached Claude was `{a:b}` and it
|
|
1111
|
+
exited with "Invalid JSON provided to --settings". The string contained
|
|
1112
|
+
every word a substring check looks for.
|
|
1113
|
+
"""
|
|
1114
|
+
import shlex
|
|
1115
|
+
|
|
1116
|
+
def ajustes_de(linea):
|
|
1117
|
+
"""The `--settings` value, dug out through however many shells wrap it.
|
|
1118
|
+
|
|
1119
|
+
A repo window is launched through `tmux send-keys`, so its whole command
|
|
1120
|
+
is one quoted word inside the line; the chair's is not. Splitting once
|
|
1121
|
+
and giving up would test the chair and quietly skip the houses — which
|
|
1122
|
+
is exactly the window that broke.
|
|
1123
|
+
"""
|
|
1124
|
+
pendientes, visto = [linea], 0
|
|
1125
|
+
while pendientes and visto < 6:
|
|
1126
|
+
visto += 1
|
|
1127
|
+
actual = pendientes.pop(0)
|
|
1128
|
+
try:
|
|
1129
|
+
palabras = shlex.split(actual)
|
|
1130
|
+
except ValueError:
|
|
1131
|
+
continue
|
|
1132
|
+
if "--settings" in palabras:
|
|
1133
|
+
return palabras[palabras.index("--settings") + 1]
|
|
1134
|
+
pendientes.extend(x for x in palabras if "--settings" in x and x != actual)
|
|
1135
|
+
return None
|
|
1136
|
+
|
|
1137
|
+
for etiqueta, linea in (("the chair", asiento), ("an agent house", api)):
|
|
1138
|
+
crudo = ajustes_de(linea)
|
|
1139
|
+
if crudo is None:
|
|
1140
|
+
afirma(f"· {etiqueta} is launched with --settings", False, linea[:250])
|
|
1141
|
+
continue
|
|
1142
|
+
try:
|
|
1143
|
+
ajustes = json.loads(crudo)
|
|
1144
|
+
except json.JSONDecodeError as e:
|
|
1145
|
+
afirma(f"· {etiqueta}'s --settings is still JSON after the shell has it",
|
|
1146
|
+
False, f"{crudo!r}: {e}")
|
|
1147
|
+
continue
|
|
1148
|
+
afirma(f"· {etiqueta}'s --settings is still JSON after the shell has it",
|
|
1149
|
+
isinstance(ajustes, dict), crudo)
|
|
1150
|
+
comprueba(f"· and {etiqueta} still refuses cross-session inbound",
|
|
1151
|
+
ajustes.get("crossSessionInbound"), "refuse")
|
|
1152
|
+
|
|
1153
|
+
|
|
1104
1154
|
def arranque_escalonado():
|
|
1105
1155
|
"""Claude windows must not all start in the same millisecond.
|
|
1106
1156
|
|
|
@@ -1310,6 +1360,8 @@ def arranque_escalonado():
|
|
|
1310
1360
|
"CITY_BUS_URL= CITY_BUS_TOKEN=" in api and "CITY_BUS_URL= CITY_BUS_TOKEN=" in docs,
|
|
1311
1361
|
)
|
|
1312
1362
|
claude_contract = (asiento + "\n" + api).replace("\\", "")
|
|
1363
|
+
el_settings_sobrevive_a_la_shell(asiento, api)
|
|
1364
|
+
|
|
1313
1365
|
afirma(
|
|
1314
1366
|
"· Claude native peer messaging is refused and its tools denied",
|
|
1315
1367
|
claude_contract.count("crossSessionInbound") == 2
|
package/bin/test-serve.py
CHANGED
|
@@ -937,7 +937,31 @@ def main():
|
|
|
937
937
|
not os.path.exists(os.path.join(hallDatos, "units.yml"))
|
|
938
938
|
or "u1" not in open(os.path.join(hallDatos, "units.yml")).read(),
|
|
939
939
|
)
|
|
940
|
+
# Observing must not create.
|
|
941
|
+
#
|
|
942
|
+
# The journal runs after the handler, and it used to resolve its city
|
|
943
|
+
# through `ciudad`, which does `makedirs` and writes `city.yml`. On a
|
|
944
|
+
# request that archived a city, the log line recreated the folder that
|
|
945
|
+
# had just been taken away — a ghost city, left behind by the act of
|
|
946
|
+
# recording that it had gone. It also raced this very cleanup.
|
|
940
947
|
shutil.rmtree(otra)
|
|
948
|
+
# The resolver the journal uses, on its own: it must answer where
|
|
949
|
+
# to write without bringing anything into being.
|
|
950
|
+
#
|
|
951
|
+
# (A handler is a different matter. It resolves through `ciudad`, which
|
|
952
|
+
# creates metadata for a city it is about to act on, so a request naming
|
|
953
|
+
# a deleted city can recreate its folder. That predates the journal and
|
|
954
|
+
# is not what this is about.)
|
|
955
|
+
fantasma = os.path.join(tempfile.mkdtemp(), "no-existe")
|
|
956
|
+
manejador = serve.Manejador.__new__(serve.Manejador)
|
|
957
|
+
donde = manejador._ciudad_para_apuntar(
|
|
958
|
+
{"city": [fantasma]}
|
|
959
|
+
)
|
|
960
|
+
afirma(
|
|
961
|
+
"· the journal's own resolver creates nothing",
|
|
962
|
+
not os.path.exists(fantasma) and isinstance(donde, str),
|
|
963
|
+
f"{fantasma} exists after only asking where to log",
|
|
964
|
+
)
|
|
941
965
|
|
|
942
966
|
# ── the demo's remote control ────────────────────────────────────────
|
|
943
967
|
print(" the demo's remote control")
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "city",
|
|
3
3
|
"displayName": "Agents City",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.3",
|
|
5
5
|
"description": "One autonomous city seat with a work domain, role, goal, repo support agents, recognised skills and explicit roads to other cities.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "jlcases",
|
package/plugin/scripts/arnes.py
CHANGED
|
@@ -24,6 +24,7 @@ Three columns, and the distinction between them is the whole point:
|
|
|
24
24
|
|
|
25
25
|
import json
|
|
26
26
|
import os
|
|
27
|
+
import shlex
|
|
27
28
|
|
|
28
29
|
AQUI = os.path.dirname(os.path.abspath(__file__))
|
|
29
30
|
RAIZ = os.path.dirname(os.path.dirname(AQUI))
|
|
@@ -200,7 +201,15 @@ def banderas(nombre):
|
|
|
200
201
|
flags.append(f"--{guion} {valor}")
|
|
201
202
|
partes = []
|
|
202
203
|
if ajustes:
|
|
203
|
-
|
|
204
|
+
# Quoted for a shell, because a shell is what receives this.
|
|
205
|
+
#
|
|
206
|
+
# Emitted bare, `--settings {"a":"b","c":true}` is destroyed twice over
|
|
207
|
+
# before Claude ever sees it: brace expansion splits it on the comma,
|
|
208
|
+
# and quote removal eats the double quotes. What arrived was
|
|
209
|
+
# `--settings {a:b}` and every window died with "Invalid JSON provided
|
|
210
|
+
# to --settings". The line this replaced was `--settings '$SETTINGS'`,
|
|
211
|
+
# and the quotes were the part that mattered.
|
|
212
|
+
partes.append("--settings " + shlex.quote(json.dumps(ajustes, separators=(",", ":"))))
|
|
204
213
|
partes += flags
|
|
205
214
|
return " ".join(partes)
|
|
206
215
|
|