kanibako-cli 1.5.0.dev14__py3-none-any.whl

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 (85) hide show
  1. kanibako/__init__.py +3 -0
  2. kanibako/__main__.py +6 -0
  3. kanibako/auth_browser.py +296 -0
  4. kanibako/auth_parser.py +51 -0
  5. kanibako/browser_sidecar.py +183 -0
  6. kanibako/browser_state.py +103 -0
  7. kanibako/bun_sea.py +144 -0
  8. kanibako/cli.py +344 -0
  9. kanibako/commands/__init__.py +0 -0
  10. kanibako/commands/archive.py +228 -0
  11. kanibako/commands/box/__init__.py +22 -0
  12. kanibako/commands/box/_duplicate.py +395 -0
  13. kanibako/commands/box/_migrate.py +574 -0
  14. kanibako/commands/box/_parser.py +1178 -0
  15. kanibako/commands/clean.py +166 -0
  16. kanibako/commands/crab_cmd.py +480 -0
  17. kanibako/commands/diagnose.py +239 -0
  18. kanibako/commands/fork_cmd.py +51 -0
  19. kanibako/commands/helper_cmd.py +669 -0
  20. kanibako/commands/image.py +1300 -0
  21. kanibako/commands/install.py +152 -0
  22. kanibako/commands/refresh_credentials.py +67 -0
  23. kanibako/commands/restore.py +298 -0
  24. kanibako/commands/setup_cmd.py +89 -0
  25. kanibako/commands/start.py +1600 -0
  26. kanibako/commands/stop.py +116 -0
  27. kanibako/commands/system_cmd.py +224 -0
  28. kanibako/commands/upgrade.py +161 -0
  29. kanibako/commands/vault_cmd.py +199 -0
  30. kanibako/commands/workset_cmd.py +552 -0
  31. kanibako/config.py +514 -0
  32. kanibako/config_interface.py +573 -0
  33. kanibako/config_io.py +36 -0
  34. kanibako/container.py +607 -0
  35. kanibako/containerfiles.py +58 -0
  36. kanibako/containers/Containerfile.kanibako +99 -0
  37. kanibako/containers/Containerfile.template-android +55 -0
  38. kanibako/containers/Containerfile.template-dotnet +29 -0
  39. kanibako/containers/Containerfile.template-js +43 -0
  40. kanibako/containers/Containerfile.template-jvm +27 -0
  41. kanibako/containers/Containerfile.template-systems +46 -0
  42. kanibako/containers/__init__.py +0 -0
  43. kanibako/crabs.py +89 -0
  44. kanibako/errors.py +33 -0
  45. kanibako/freshness.py +67 -0
  46. kanibako/git.py +114 -0
  47. kanibako/helper_client.py +132 -0
  48. kanibako/helper_listener.py +538 -0
  49. kanibako/helpers.py +339 -0
  50. kanibako/hygiene.py +296 -0
  51. kanibako/image_sharing.py +133 -0
  52. kanibako/instructions.py +160 -0
  53. kanibako/log.py +31 -0
  54. kanibako/names.py +248 -0
  55. kanibako/paths.py +1483 -0
  56. kanibako/plugins/__init__.py +10 -0
  57. kanibako/registry.py +71 -0
  58. kanibako/rig_bundle.py +121 -0
  59. kanibako/rig_meta.py +92 -0
  60. kanibako/rig_registry.py +132 -0
  61. kanibako/rig_resolve.py +182 -0
  62. kanibako/rig_source.py +245 -0
  63. kanibako/scripts/__init__.py +0 -0
  64. kanibako/scripts/helper-init.sh +45 -0
  65. kanibako/scripts/kanibako-entry +12 -0
  66. kanibako/settings_resolve.py +312 -0
  67. kanibako/settings_seeds.py +154 -0
  68. kanibako/settings_shares.py +154 -0
  69. kanibako/shellenv.py +75 -0
  70. kanibako/snapshots.py +281 -0
  71. kanibako/targets/__init__.py +173 -0
  72. kanibako/targets/base.py +243 -0
  73. kanibako/targets/no_agent.py +58 -0
  74. kanibako/templates.py +60 -0
  75. kanibako/templates_image.py +224 -0
  76. kanibako/tweakcc.py +140 -0
  77. kanibako/tweakcc_cache.py +171 -0
  78. kanibako/utils.py +136 -0
  79. kanibako/workset.py +347 -0
  80. kanibako_cli-1.5.0.dev14.dist-info/METADATA +15 -0
  81. kanibako_cli-1.5.0.dev14.dist-info/RECORD +85 -0
  82. kanibako_cli-1.5.0.dev14.dist-info/WHEEL +5 -0
  83. kanibako_cli-1.5.0.dev14.dist-info/entry_points.txt +5 -0
  84. kanibako_cli-1.5.0.dev14.dist-info/licenses/LICENSE.md +594 -0
  85. kanibako_cli-1.5.0.dev14.dist-info/top_level.txt +1 -0
@@ -0,0 +1,132 @@
1
+ """Container-side helper client: socket communication with the host hub."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import socket
7
+ import threading
8
+ from pathlib import Path
9
+
10
+
11
+ class HelperConnection:
12
+ """Persistent connection to the HelperHub for messaging.
13
+
14
+ Use for helpers that need to send/receive messages over time.
15
+ For one-shot commands (spawn/stop), use ``send_request()`` instead.
16
+ """
17
+
18
+ def __init__(self) -> None:
19
+ self._sock: socket.socket | None = None
20
+ self._recv_buf = b""
21
+ self._lock = threading.Lock()
22
+
23
+ def connect(self, socket_path: Path, helper_num: int | None = None) -> None:
24
+ """Connect to the hub socket, optionally registering as a helper."""
25
+ self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
26
+ self._sock.connect(str(socket_path))
27
+ if helper_num is not None:
28
+ resp = self._request({"action": "register", "helper_num": helper_num})
29
+ if resp.get("status") != "ok":
30
+ raise ConnectionError(
31
+ f"Registration failed: {resp.get('message', 'unknown')}"
32
+ )
33
+
34
+ def spawn(self, helper_num: int, model: str | None = None,
35
+ helpers_dir: str | None = None) -> dict:
36
+ """Request the hub to spawn a helper container."""
37
+ req: dict = {"action": "spawn", "helper_num": helper_num}
38
+ if model:
39
+ req["model"] = model
40
+ if helpers_dir:
41
+ req["helpers_dir"] = helpers_dir
42
+ return self._request(req)
43
+
44
+ def stop(self, container_name: str) -> dict:
45
+ """Request the hub to stop a helper container."""
46
+ return self._request({
47
+ "action": "stop",
48
+ "container_name": container_name,
49
+ })
50
+
51
+ def send(self, to: int, payload: dict) -> dict:
52
+ """Send a message to a specific peer or parent."""
53
+ return self._request({
54
+ "action": "send", "to": to, "payload": payload,
55
+ })
56
+
57
+ def broadcast(self, payload: dict) -> dict:
58
+ """Broadcast a message to all connected helpers."""
59
+ return self._request({
60
+ "action": "broadcast", "payload": payload,
61
+ })
62
+
63
+ def recv(self, timeout: float | None = None) -> dict | None:
64
+ """Receive an incoming message (blocking).
65
+
66
+ Returns the message dict or None on timeout/disconnect.
67
+ """
68
+ if self._sock is None:
69
+ return None
70
+ old_timeout = self._sock.gettimeout()
71
+ self._sock.settimeout(timeout)
72
+ try:
73
+ while b"\n" not in self._recv_buf:
74
+ try:
75
+ data = self._sock.recv(4096)
76
+ except socket.timeout:
77
+ return None
78
+ except OSError:
79
+ return None
80
+ if not data:
81
+ return None
82
+ self._recv_buf += data
83
+ line, self._recv_buf = self._recv_buf.split(b"\n", 1)
84
+ return json.loads(line)
85
+ finally:
86
+ self._sock.settimeout(old_timeout)
87
+
88
+ def close(self) -> None:
89
+ """Close the connection."""
90
+ if self._sock:
91
+ try:
92
+ self._sock.close()
93
+ except Exception:
94
+ pass
95
+ self._sock = None
96
+
97
+ def _request(self, data: dict) -> dict:
98
+ """Send a request and read the response."""
99
+ if self._sock is None:
100
+ raise ConnectionError("Not connected")
101
+ with self._lock:
102
+ self._sock.sendall(json.dumps(data).encode() + b"\n")
103
+ # Read response from buffer + socket
104
+ while b"\n" not in self._recv_buf:
105
+ chunk = self._sock.recv(4096)
106
+ if not chunk:
107
+ raise ConnectionError("Connection closed")
108
+ self._recv_buf += chunk
109
+ line, self._recv_buf = self._recv_buf.split(b"\n", 1)
110
+ return json.loads(line)
111
+
112
+
113
+ def send_request(socket_path: Path, request: dict) -> dict:
114
+ """One-shot convenience: connect, send, read response, disconnect.
115
+
116
+ For spawn/stop commands that don't need a persistent connection.
117
+ """
118
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
119
+ try:
120
+ s.connect(str(socket_path))
121
+ s.settimeout(30.0)
122
+ s.sendall(json.dumps(request).encode() + b"\n")
123
+ buf = b""
124
+ while b"\n" not in buf:
125
+ data = s.recv(4096)
126
+ if not data:
127
+ raise ConnectionError("Connection closed before response")
128
+ buf += data
129
+ line = buf.split(b"\n")[0]
130
+ return json.loads(line)
131
+ finally:
132
+ s.close()
@@ -0,0 +1,538 @@
1
+ """Host-side helper hub: Unix socket server for spawn/stop and message routing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import socket
8
+ import threading
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from kanibako.container import ContainerRuntime
14
+ from kanibako.log import get_logger
15
+ from kanibako.targets.base import Mount
16
+
17
+ logger = get_logger("helper_listener")
18
+
19
+
20
+ @dataclass
21
+ class HelperContext:
22
+ """Everything needed to launch helper containers from the host."""
23
+
24
+ runtime: ContainerRuntime
25
+ image: str
26
+ container_name_prefix: str # e.g. "kanibako-myapp" (project container name)
27
+ shell_path: Path # director's shell_path (parent of helpers/)
28
+ helpers_dir: Path # absolute host path to helpers/ inside shell_path
29
+ socket_path: Path # host path to helper.sock
30
+ binary_mounts: list[Mount] = field(default_factory=list)
31
+ env: dict[str, str] | None = None
32
+ entrypoint: str | None = None
33
+ default_entrypoint: str | None = None # from target.default_entrypoint
34
+ project_path: Path | None = None # host-side workspace directory
35
+ data_path: Path | None = None # kanibako data root (~/.local/share/kanibako/)
36
+ boxes: Path | None = None # resolved system.path.boxes (std.boxes)
37
+
38
+
39
+ class HelperHub:
40
+ """Central message router and container orchestrator.
41
+
42
+ Runs a Unix domain socket server in a background thread. Helpers
43
+ connect and send JSON-line requests; the hub dispatches spawn/stop
44
+ commands and routes messages between helpers.
45
+ """
46
+
47
+ def __init__(self) -> None:
48
+ self._sock: socket.socket | None = None
49
+ self._accept_thread: threading.Thread | None = None
50
+ self._shutdown = threading.Event()
51
+ self._ctx: HelperContext | None = None
52
+ self._log: MessageLog | None = None
53
+
54
+ # Connection table: helper_num -> socket connection
55
+ self._connections: dict[int, socket.socket] = {}
56
+ self._conn_lock = threading.Lock()
57
+
58
+ # Track launched container names for cleanup
59
+ self._containers: list[str] = []
60
+ self._containers_lock = threading.Lock()
61
+
62
+ def start(self, socket_path: Path, context: HelperContext,
63
+ log: MessageLog | None = None) -> None:
64
+ """Bind the Unix socket and start the accept loop."""
65
+ self._ctx = context
66
+ self._log = log
67
+
68
+ # Ensure parent dir exists, remove stale socket
69
+ socket_path.parent.mkdir(parents=True, exist_ok=True)
70
+ if socket_path.exists():
71
+ socket_path.unlink()
72
+
73
+ self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
74
+ self._sock.bind(str(socket_path))
75
+ self._sock.listen(16)
76
+ self._sock.settimeout(1.0) # so accept loop checks shutdown flag
77
+
78
+ self._accept_thread = threading.Thread(
79
+ target=self._accept_loop, daemon=True, name="helper-hub",
80
+ )
81
+ self._accept_thread.start()
82
+ logger.debug("HelperHub listening on %s", socket_path)
83
+
84
+ def stop(self) -> None:
85
+ """Shut down: stop all helper containers, close socket."""
86
+ self._shutdown.set()
87
+
88
+ # Stop all tracked helper containers
89
+ if self._ctx:
90
+ with self._containers_lock:
91
+ for name in self._containers:
92
+ try:
93
+ self._ctx.runtime.stop(name)
94
+ self._ctx.runtime.rm(name)
95
+ except Exception:
96
+ pass
97
+ self._containers.clear()
98
+
99
+ # Close all client connections
100
+ with self._conn_lock:
101
+ for conn in self._connections.values():
102
+ try:
103
+ conn.close()
104
+ except Exception:
105
+ pass
106
+ self._connections.clear()
107
+
108
+ # Close server socket
109
+ if self._sock:
110
+ try:
111
+ self._sock.close()
112
+ except Exception:
113
+ pass
114
+ self._sock = None
115
+
116
+ if self._accept_thread:
117
+ self._accept_thread.join(timeout=5.0)
118
+ self._accept_thread = None
119
+
120
+ if self._log:
121
+ self._log.close()
122
+
123
+ def _accept_loop(self) -> None:
124
+ """Accept incoming connections until shutdown."""
125
+ while not self._shutdown.is_set():
126
+ try:
127
+ conn, _ = self._sock.accept() # type: ignore[union-attr]
128
+ t = threading.Thread(
129
+ target=self._client_reader,
130
+ args=(conn,),
131
+ daemon=True,
132
+ )
133
+ t.start()
134
+ except socket.timeout:
135
+ continue
136
+ except OSError:
137
+ if not self._shutdown.is_set():
138
+ logger.debug("Accept loop OSError (shutting down?)")
139
+ break
140
+
141
+ def _client_reader(self, conn: socket.socket) -> None:
142
+ """Read newline-delimited JSON from a client connection."""
143
+ helper_num: int | None = None
144
+ buf = b""
145
+ try:
146
+ while not self._shutdown.is_set():
147
+ try:
148
+ data = conn.recv(4096)
149
+ except OSError:
150
+ break
151
+ if not data:
152
+ break
153
+ buf += data
154
+ while b"\n" in buf:
155
+ line, buf = buf.split(b"\n", 1)
156
+ if not line.strip():
157
+ continue
158
+ try:
159
+ request = json.loads(line)
160
+ except json.JSONDecodeError:
161
+ _send_json(conn, {"status": "error", "message": "invalid JSON"})
162
+ continue
163
+ response, helper_num = self._dispatch(
164
+ conn, request, helper_num,
165
+ )
166
+ if response is not None:
167
+ _send_json(conn, response)
168
+ finally:
169
+ if helper_num is not None:
170
+ self._unregister(helper_num)
171
+ if self._log:
172
+ self._log.log_control("disconnect", helper_num)
173
+ try:
174
+ conn.close()
175
+ except Exception:
176
+ pass
177
+
178
+ def _dispatch(
179
+ self, conn: socket.socket, request: dict,
180
+ current_helper: int | None,
181
+ ) -> tuple[dict | None, int | None]:
182
+ """Route a request to the appropriate handler.
183
+
184
+ Returns (response_dict, updated_helper_num).
185
+ """
186
+ action = request.get("action", "")
187
+
188
+ if action == "register":
189
+ helper_num = int(request.get("helper_num", -1))
190
+ if helper_num < 0:
191
+ return {"status": "error", "message": "invalid helper_num"}, current_helper
192
+ self._register(helper_num, conn)
193
+ if self._log:
194
+ self._log.log_control("register", helper_num)
195
+ return {"status": "ok"}, helper_num
196
+
197
+ if action == "spawn":
198
+ resp = self._handle_spawn(request)
199
+ return resp, current_helper
200
+
201
+ if action == "stop":
202
+ resp = self._handle_stop(request)
203
+ return resp, current_helper
204
+
205
+ if action == "fork":
206
+ resp = self._handle_fork(request)
207
+ return resp, current_helper
208
+
209
+ if action == "send":
210
+ to = request.get("to")
211
+ payload = request.get("payload", {})
212
+ sender = current_helper if current_helper is not None else 0
213
+ if to is None:
214
+ return {"status": "error", "message": "missing 'to'"}, current_helper
215
+ self._route_message(sender, int(to), payload)
216
+ return {"status": "ok"}, current_helper
217
+
218
+ if action == "broadcast":
219
+ payload = request.get("payload", {})
220
+ sender = current_helper if current_helper is not None else 0
221
+ self._broadcast_message(sender, payload)
222
+ return {"status": "ok"}, current_helper
223
+
224
+ return {"status": "error", "message": f"unknown action: {action}"}, current_helper
225
+
226
+ def _register(self, helper_num: int, conn: socket.socket) -> None:
227
+ with self._conn_lock:
228
+ self._connections[helper_num] = conn
229
+
230
+ def _unregister(self, helper_num: int) -> None:
231
+ with self._conn_lock:
232
+ self._connections.pop(helper_num, None)
233
+
234
+ def _route_message(self, sender: int, recipient: int,
235
+ payload: dict) -> None:
236
+ """Send a message to a specific helper."""
237
+ if self._log:
238
+ self._log.log_message(sender, recipient, payload)
239
+ with self._conn_lock:
240
+ conn = self._connections.get(recipient)
241
+ if conn:
242
+ msg = {"event": "message", "from": sender, "payload": payload}
243
+ try:
244
+ _send_json(conn, msg)
245
+ except OSError:
246
+ logger.debug("Failed to deliver to helper %d", recipient)
247
+
248
+ def _broadcast_message(self, sender: int, payload: dict) -> None:
249
+ """Send a message to all connected helpers."""
250
+ if self._log:
251
+ self._log.log_message(sender, "all", payload)
252
+ with self._conn_lock:
253
+ targets = list(self._connections.items())
254
+ msg = {"event": "message", "from": sender, "payload": payload}
255
+ for num, conn in targets:
256
+ if num == sender:
257
+ continue
258
+ try:
259
+ _send_json(conn, msg)
260
+ except OSError:
261
+ logger.debug("Failed to broadcast to helper %d", num)
262
+
263
+ def _handle_spawn(self, request: dict) -> dict:
264
+ """Launch a helper container."""
265
+ ctx = self._ctx
266
+ if ctx is None:
267
+ return {"status": "error", "message": "no context"}
268
+
269
+ helper_num = int(request.get("helper_num", -1))
270
+ if helper_num < 0:
271
+ return {"status": "error", "message": "invalid helper_num"}
272
+
273
+ helpers_dir = request.get("helpers_dir")
274
+ if helpers_dir:
275
+ # Container-side path; map to host path via ctx
276
+ helpers_dir_host = ctx.helpers_dir
277
+ else:
278
+ helpers_dir_host = ctx.helpers_dir
279
+
280
+ container_name = f"{ctx.container_name_prefix}-helper-{helper_num}"
281
+
282
+ mounts = _build_helper_mounts(ctx, helper_num, helpers_dir_host)
283
+
284
+ # Use helper-init.sh as entrypoint wrapper — it registers with the
285
+ # hub, sources broadcast scripts, then execs the agent command.
286
+ init_script = "/home/agent/playbook/scripts/helper-init.sh"
287
+ agent_cmd = ctx.entrypoint or ctx.default_entrypoint or "/bin/bash"
288
+ cli_args = [str(helper_num), agent_cmd]
289
+ model = request.get("model")
290
+ if model:
291
+ cli_args.extend(["--model", model])
292
+
293
+ try:
294
+ rc = ctx.runtime.run(
295
+ ctx.image,
296
+ shell_path=helpers_dir_host / str(helper_num),
297
+ project_path=helpers_dir_host / str(helper_num) / "workspace",
298
+ vault_ro_path=helpers_dir_host / str(helper_num) / "vault" / "ro",
299
+ vault_rw_path=helpers_dir_host / str(helper_num) / "vault" / "rw",
300
+ extra_mounts=mounts or None,
301
+ enable_vault=True,
302
+ env=ctx.env,
303
+ name=container_name,
304
+ entrypoint=init_script,
305
+ cli_args=cli_args,
306
+ detach=True,
307
+ )
308
+ except Exception as e:
309
+ return {"status": "error", "message": str(e)}
310
+
311
+ if rc != 0:
312
+ return {"status": "error", "message": f"container exited with {rc}"}
313
+
314
+ with self._containers_lock:
315
+ self._containers.append(container_name)
316
+
317
+ if self._log:
318
+ self._log.log_control(
319
+ "spawn", helper_num,
320
+ model=request.get("model"),
321
+ )
322
+
323
+ return {"status": "ok", "container_name": container_name}
324
+
325
+ def _handle_stop(self, request: dict) -> dict:
326
+ """Stop and remove a helper container."""
327
+ ctx = self._ctx
328
+ if ctx is None:
329
+ return {"status": "error", "message": "no context"}
330
+
331
+ container_name = request.get("container_name", "")
332
+ if not container_name:
333
+ return {"status": "error", "message": "missing container_name"}
334
+
335
+ ctx.runtime.stop(container_name)
336
+ ctx.runtime.rm(container_name)
337
+
338
+ with self._containers_lock:
339
+ if container_name in self._containers:
340
+ self._containers.remove(container_name)
341
+
342
+ # Extract helper_num from container name if possible
343
+ helper_num = _parse_helper_num(container_name)
344
+ if self._log and helper_num is not None:
345
+ self._log.log_control("stop", helper_num)
346
+
347
+ return {"status": "ok"}
348
+
349
+ def _handle_fork(self, request: dict) -> dict:
350
+ """Fork the current project into a sibling directory."""
351
+ ctx = self._ctx
352
+ if ctx is None:
353
+ return {"status": "error", "message": "no context"}
354
+ if ctx.project_path is None or ctx.data_path is None:
355
+ return {"status": "error", "message": "fork requires project_path and data_path"}
356
+
357
+ name = request.get("name", "")
358
+ if not name:
359
+ return {"status": "error", "message": "missing fork name"}
360
+
361
+ # Validate name: no path separators, no dots, not empty after strip
362
+ name = name.strip()
363
+ if not name or "/" in name or "\\" in name or "." in name:
364
+ return {"status": "error", "message": "invalid fork name (no slashes or dots)"}
365
+
366
+ # Compute destination
367
+ new_path = ctx.project_path.parent / f"{ctx.project_path.name}.{name}"
368
+ if new_path.exists():
369
+ return {"status": "error", "message": f"destination already exists: {new_path}"}
370
+
371
+ # Copy workspace
372
+ try:
373
+ shutil.copytree(ctx.project_path, new_path)
374
+ except Exception as e:
375
+ return {"status": "error", "message": f"workspace copy failed: {e}"}
376
+
377
+ # Resolve source metadata dir via names.yaml reverse lookup
378
+ from kanibako.names import assign_name, read_names
379
+
380
+ boxes_base = ctx.boxes or (ctx.data_path / "boxes")
381
+ source_meta_dir: Path | None = None
382
+ names = read_names(ctx.data_path)
383
+ for rname, rpath in names["projects"].items():
384
+ if rpath == str(ctx.project_path):
385
+ candidate = boxes_base / rname
386
+ if candidate.is_dir():
387
+ source_meta_dir = candidate
388
+ break
389
+
390
+ # Fallback: derive from shell_path (shell_path is typically boxes/{name}/shell/)
391
+ if source_meta_dir is None:
392
+ candidate = ctx.shell_path.parent
393
+ if candidate.is_dir() and candidate.parent.name == "boxes":
394
+ source_meta_dir = candidate
395
+
396
+ # Assign a new name for the fork
397
+ try:
398
+ new_name = assign_name(ctx.data_path, str(new_path))
399
+ except Exception as e:
400
+ return {"status": "error", "message": f"name assignment failed: {e}"}
401
+
402
+ # Copy metadata if we found the source
403
+ if source_meta_dir is not None:
404
+ new_meta_dir = boxes_base / new_name
405
+ try:
406
+ if not new_meta_dir.exists():
407
+ shutil.copytree(
408
+ source_meta_dir, new_meta_dir,
409
+ ignore=shutil.ignore_patterns(
410
+ ".kanibako.lock", "helpers",
411
+ ),
412
+ )
413
+ except Exception as e:
414
+ logger.warning("metadata copy failed: %s", e)
415
+
416
+ if self._log:
417
+ self._log.log_control("fork", name=name, path=str(new_path),
418
+ new_name=new_name)
419
+
420
+ return {"status": "ok", "path": str(new_path), "name": new_name}
421
+
422
+
423
+ _LOG_MAX_BYTES = 1_048_576 # 1 MiB
424
+
425
+
426
+ class MessageLog:
427
+ """Append-only JSONL log with size-based rotation."""
428
+
429
+ def __init__(self, log_path: Path) -> None:
430
+ self._path = log_path
431
+ log_path.parent.mkdir(parents=True, exist_ok=True)
432
+ self._file = open(log_path, "a")
433
+ self._lock = threading.Lock()
434
+
435
+ def log_message(self, sender: int, recipient: int | str,
436
+ payload: dict) -> None:
437
+ """Record a message event."""
438
+ self._write({
439
+ "type": "message",
440
+ "from": sender,
441
+ "to": recipient,
442
+ "payload": payload,
443
+ })
444
+
445
+ def log_control(self, event: str, helper: int | None = None,
446
+ **extra: Any) -> None:
447
+ """Record a control event (spawn, stop, register, disconnect)."""
448
+ entry: dict[str, Any] = {"type": "control", "event": event}
449
+ if helper is not None:
450
+ entry["helper"] = helper
451
+ entry.update(extra)
452
+ self._write(entry)
453
+
454
+ def _write(self, entry: dict) -> None:
455
+ from datetime import datetime, timezone
456
+ entry["ts"] = datetime.now(timezone.utc).isoformat()
457
+ with self._lock:
458
+ self._file.write(json.dumps(entry) + "\n")
459
+ self._file.flush()
460
+ self._rotate_if_needed()
461
+
462
+ def _rotate_if_needed(self) -> None:
463
+ """Rotate log file when it exceeds the size threshold.
464
+
465
+ Caller must hold ``self._lock``.
466
+ """
467
+ try:
468
+ pos = self._file.tell()
469
+ except OSError:
470
+ return
471
+ if pos < _LOG_MAX_BYTES:
472
+ return
473
+ self._file.close()
474
+ backup = self._path.with_suffix(self._path.suffix + ".1")
475
+ self._path.rename(backup)
476
+ self._file = open(self._path, "a")
477
+
478
+ def close(self) -> None:
479
+ with self._lock:
480
+ self._file.close()
481
+
482
+
483
+ def _send_json(conn: socket.socket, data: dict) -> None:
484
+ """Send a JSON object followed by newline."""
485
+ conn.sendall(json.dumps(data).encode() + b"\n")
486
+
487
+
488
+ def _build_helper_mounts(ctx: HelperContext, helper_num: int,
489
+ helpers_dir: Path) -> list[Mount]:
490
+ """Build bind mounts for a helper container."""
491
+ helper_root = helpers_dir / str(helper_num)
492
+ mounts: list[Mount] = []
493
+
494
+ # Peers directory
495
+ peers_dir = helper_root / "peers"
496
+ if peers_dir.is_dir():
497
+ mounts.append(Mount(peers_dir, "/home/agent/peers", "Z,U"))
498
+
499
+ # Broadcast directory
500
+ all_link = helper_root / "all"
501
+ if all_link.exists():
502
+ mounts.append(Mount(all_link, "/home/agent/all", "Z,U"))
503
+
504
+ # Spawn config (read-only)
505
+ spawn_toml = helper_root / "spawn.yaml"
506
+ if spawn_toml.is_file():
507
+ mounts.append(Mount(spawn_toml, "/home/agent/spawn.yaml", "ro"))
508
+
509
+ # Helper socket — mount the hub socket into the helper
510
+ if ctx.socket_path.exists():
511
+ kanibako_dir = helper_root / ".local" / "state" / "kanibako"
512
+ kanibako_dir.mkdir(parents=True, exist_ok=True)
513
+ mounts.append(
514
+ Mount(ctx.socket_path, "/home/agent/.local/state/kanibako/helper.sock", "")
515
+ )
516
+
517
+ # Target binary mounts (same agent binary as the director)
518
+ mounts.extend(ctx.binary_mounts)
519
+
520
+ return mounts
521
+
522
+
523
+ def _parse_helper_num(container_name: str) -> int | None:
524
+ """Extract helper number from a container name.
525
+
526
+ Handles both formats:
527
+ - New: ``kanibako-{name}-helper-{N}``
528
+ - Legacy: ``kanibako-helper-{N}-{hash}``
529
+ """
530
+ parts = container_name.split("-")
531
+ # Walk backwards looking for "helper" followed by a numeric part.
532
+ for i in range(len(parts) - 1, 0, -1):
533
+ if parts[i - 1] == "helper":
534
+ try:
535
+ return int(parts[i])
536
+ except ValueError:
537
+ pass
538
+ return None