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.
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "city",
11
11
  "source": "./plugin",
12
- "version": "0.5.2",
12
+ "version": "0.5.3",
13
13
  "description": "Operate one autonomous city seat, its domain, role, repo support agents, goal, recognised skills and explicit roads to other cities."
14
14
  }
15
15
  ]
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.ciudad(q), tipo, **campos)
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")
@@ -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
- road_notices = [m for m in b.mensajes
402
- if m.get('method') == 'notifications/claude/channel']
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,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-city",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Run autonomous agent cities: one chair seat, role-aware repo agents, repo-owned skills, and explicit roads.",
5
5
  "bin": {
6
6
  "agents-city": "bin/agents-city.js"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "city",
3
3
  "displayName": "Agents City",
4
- "version": "0.5.2",
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",
@@ -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
- partes.append("--settings " + json.dumps(ajustes, separators=(",", ":")))
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