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,1600 @@
1
+ """kanibako start / shell: container launch with credential flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import fcntl
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from kanibako.crabs import load_crab_config, write_crab_config
14
+ from kanibako.config import config_file_path, load_config, load_merged_config
15
+ from kanibako.container import ContainerRuntime
16
+ from kanibako.errors import ContainerError
17
+ from kanibako.log import get_logger
18
+ from kanibako.rig_registry import load_registry, registry_path
19
+ from kanibako.rig_resolve import resolve_rig
20
+ from kanibako.paths import (
21
+ _upgrade_shell,
22
+ xdg,
23
+ load_std_paths,
24
+ resolve_any_project,
25
+ )
26
+ from kanibako.targets import resolve_target
27
+ from kanibako.utils import container_name_for, short_hash
28
+
29
+
30
+ def add_start_parser(subparsers: argparse._SubParsersAction) -> None:
31
+ p = subparsers.add_parser(
32
+ "start",
33
+ help="Start or continue an agent session (default)",
34
+ description="Start or continue an agent session in a container.",
35
+ )
36
+
37
+ # Start mode: -N/-C/-R mutually exclusive
38
+ mode_group = p.add_mutually_exclusive_group()
39
+ mode_group.add_argument(
40
+ "-N", "--new", action="store_true", dest="new_session",
41
+ help="Start a new conversation (skip default --continue)",
42
+ )
43
+ mode_group.add_argument(
44
+ "-C", "--continue", action="store_true", dest="continue_session",
45
+ help="Continue the most recent conversation (default for existing projects)",
46
+ )
47
+ mode_group.add_argument(
48
+ "-R", "--resume", action="store_true", dest="resume_session",
49
+ help="Resume with conversation picker",
50
+ )
51
+
52
+ # Agent mode: -A/-S mutually exclusive
53
+ agent_group = p.add_mutually_exclusive_group()
54
+ agent_group.add_argument(
55
+ "-A", "--autonomous", action="store_true",
56
+ help="Run with full permissions (--dangerously-skip-permissions)",
57
+ )
58
+ agent_group.add_argument(
59
+ "-S", "--secure", action="store_true",
60
+ help="Run without --dangerously-skip-permissions",
61
+ )
62
+
63
+ p.add_argument(
64
+ "-M", "--model", default=None,
65
+ help="Override the agent model for this run",
66
+ )
67
+ p.add_argument(
68
+ "-e", "--env", action="append", default=None, metavar="KEY=VALUE",
69
+ help="Set per-run environment variable (repeatable)",
70
+ )
71
+ p.add_argument(
72
+ "--image", default=None,
73
+ help="Use IMAGE as the container image for this run (--rig is the preferred spelling)",
74
+ )
75
+ p.add_argument(
76
+ "--rig", dest="image", default=None,
77
+ help="Rig (image) to use; synonym for --image",
78
+ )
79
+ p.add_argument(
80
+ "--entrypoint", default=None,
81
+ help="Use CMD as the container entrypoint",
82
+ )
83
+
84
+ # Session persistence mode
85
+ persist_group = p.add_mutually_exclusive_group()
86
+ persist_group.add_argument(
87
+ "--persistent", action="store_true",
88
+ help="Run in a persistent tmux session (reattach on subsequent start)",
89
+ )
90
+ persist_group.add_argument(
91
+ "--ephemeral", action="store_true",
92
+ help="Run in foreground without tmux (single-use session)",
93
+ )
94
+
95
+ p.add_argument(
96
+ "--no-helpers", action="store_true",
97
+ help="Disable helper spawning (no hub socket mounted)",
98
+ )
99
+ p.add_argument(
100
+ "--no-auto-auth", action="store_true",
101
+ help="Disable automated browser-based OAuth refresh",
102
+ )
103
+ p.add_argument(
104
+ "--browser", action="store_true",
105
+ help="Launch a headless browser sidecar (BROWSER_WS_ENDPOINT injected)",
106
+ )
107
+ p.add_argument(
108
+ "--share-images", action="store_true",
109
+ help="Share host container image storage with child (read-only, experimental)",
110
+ )
111
+ p.add_argument(
112
+ "project", nargs="?", default=None,
113
+ help="Project directory or registered name (omit for current dir)",
114
+ )
115
+ p.set_defaults(func=run_start)
116
+
117
+
118
+ def add_shell_parser(subparsers: argparse._SubParsersAction) -> None:
119
+ p = subparsers.add_parser(
120
+ "shell",
121
+ help="Open a bash shell in the container",
122
+ description="Open a bash shell in the container (no agent).",
123
+ )
124
+ p.add_argument(
125
+ "-e", "--env", action="append", default=None, metavar="KEY=VALUE",
126
+ help="Set per-run environment variable (repeatable)",
127
+ )
128
+ p.add_argument(
129
+ "--image", default=None,
130
+ help="Use IMAGE as the container image for this run (--rig is the preferred spelling)",
131
+ )
132
+ p.add_argument(
133
+ "--rig", dest="image", default=None,
134
+ help="Rig (image) to use; synonym for --image",
135
+ )
136
+ p.add_argument(
137
+ "--entrypoint", default=None,
138
+ help="Use CMD as the container entrypoint",
139
+ )
140
+
141
+ # Session persistence mode
142
+ persist_group = p.add_mutually_exclusive_group()
143
+ persist_group.add_argument(
144
+ "--persistent", action="store_true",
145
+ help="Run in a persistent tmux session (reattach on subsequent start)",
146
+ )
147
+ persist_group.add_argument(
148
+ "--ephemeral", action="store_true",
149
+ help="Run in foreground without tmux (single-use session)",
150
+ )
151
+
152
+ p.add_argument(
153
+ "--no-helpers", action="store_true",
154
+ help="Disable helper spawning (no hub socket mounted)",
155
+ )
156
+ p.add_argument(
157
+ "--share-images", action="store_true",
158
+ help="Share host container image storage with child (read-only, experimental)",
159
+ )
160
+ p.add_argument(
161
+ "project", nargs="?", default=None,
162
+ help="Project directory or registered name (omit for current dir)",
163
+ )
164
+ p.set_defaults(func=run_shell)
165
+
166
+
167
+ def run_start(args: argparse.Namespace) -> int:
168
+ entrypoint = getattr(args, "entrypoint", None)
169
+ image_override = getattr(args, "image", None)
170
+ new_session = getattr(args, "new_session", False)
171
+ resume_session = getattr(args, "resume_session", False)
172
+ secure = getattr(args, "secure", False)
173
+ model_override = getattr(args, "model", None)
174
+ no_helpers = getattr(args, "no_helpers", False)
175
+ no_auto_auth = getattr(args, "no_auto_auth", False)
176
+ browser = getattr(args, "browser", False)
177
+ share_images = getattr(args, "share_images", False)
178
+ explicit_persistent = getattr(args, "persistent", False)
179
+ explicit_ephemeral = getattr(args, "ephemeral", False)
180
+ if explicit_persistent:
181
+ persistent = True
182
+ elif explicit_ephemeral:
183
+ persistent = False
184
+ else:
185
+ # Default: persistent when tmux is available
186
+ persistent = _tmux_available()
187
+ env_vars = getattr(args, "env", None) or []
188
+ project_dir = getattr(args, "project", None)
189
+ agent_args = getattr(args, "agent_args", [])
190
+
191
+ # Map -A/-S to safe_mode: -A means autonomous (safe_mode=False),
192
+ # -S means secure (safe_mode=True). Neither means autonomous (default).
193
+ safe_mode = secure
194
+
195
+ # Check for agent before launching container.
196
+ # If no agent is detected, show a helpful message instead of silently
197
+ # launching a plain shell. run_shell() is not affected.
198
+ from kanibako.targets.no_agent import NoAgentTarget
199
+ target = resolve_target()
200
+ if isinstance(target, NoAgentTarget):
201
+ print()
202
+ print("No agents detected.")
203
+ print()
204
+ print(" Install a plugin: pip install kanibako-agent-claude")
205
+ print(" Run setup wizard: kanibako setup")
206
+ print(" Health check: kanibako system diagnose")
207
+ print(" Plain sandbox: kanibako shell")
208
+ print()
209
+ return 0
210
+
211
+ return _run_container(
212
+ project_dir=project_dir,
213
+ entrypoint=entrypoint,
214
+ image_override=image_override,
215
+ new_session=new_session,
216
+ safe_mode=safe_mode,
217
+ resume_mode=resume_session,
218
+ extra_args=agent_args,
219
+ no_helpers=no_helpers,
220
+ no_auto_auth=no_auto_auth,
221
+ browser=browser,
222
+ share_images=share_images,
223
+ persistent=persistent,
224
+ model_override=model_override,
225
+ cli_env=env_vars,
226
+ )
227
+
228
+
229
+ def run_shell(args: argparse.Namespace) -> int:
230
+ project_dir = getattr(args, "project", None)
231
+ shell_args = getattr(args, "shell_args", [])
232
+
233
+ entrypoint = getattr(args, "entrypoint", None)
234
+ if not entrypoint:
235
+ entrypoint = "/bin/sh" if shell_args else "/bin/bash"
236
+ # Wrap shell_args as -c "cmd" so /bin/sh executes them as a command
237
+ if shell_args and not getattr(args, "entrypoint", None):
238
+ shell_args = ["-c", " ".join(shell_args)]
239
+
240
+ image_override = getattr(args, "image", None)
241
+ no_helpers = getattr(args, "no_helpers", False)
242
+ share_images = getattr(args, "share_images", False)
243
+ env_vars = getattr(args, "env", None) or []
244
+
245
+ explicit_persistent = getattr(args, "persistent", False)
246
+ explicit_ephemeral = getattr(args, "ephemeral", False)
247
+ if explicit_persistent:
248
+ persistent = True
249
+ elif explicit_ephemeral:
250
+ persistent = False
251
+ else:
252
+ persistent = False # shell defaults to ephemeral
253
+
254
+ return _run_container(
255
+ project_dir=project_dir,
256
+ entrypoint=entrypoint,
257
+ image_override=image_override,
258
+ new_session=False,
259
+ safe_mode=False,
260
+ resume_mode=False,
261
+ extra_args=shell_args,
262
+ no_helpers=no_helpers,
263
+ share_images=share_images,
264
+ persistent=persistent,
265
+ cli_env=env_vars,
266
+ )
267
+
268
+
269
+ def _tmux_available() -> bool:
270
+ """Check if tmux is installed."""
271
+ return shutil.which("tmux") is not None
272
+
273
+
274
+ def _tmux_session_name(project_name: str) -> str:
275
+ """Generate a deterministic tmux session name for host-side reattach."""
276
+ return f"kanibako-{project_name}"
277
+
278
+
279
+ def _tmux_has_session(session_name: str) -> bool:
280
+ """Check if a tmux session exists on the host."""
281
+ return subprocess.run(
282
+ ["tmux", "has-session", "-t", session_name],
283
+ capture_output=True,
284
+ ).returncode == 0
285
+
286
+
287
+ def _apply_tweakcc(install, crab_cfg, cache_path, image, runtime_cmd, logger):
288
+ """Apply tweakcc patching if enabled in crab config.
289
+
290
+ Patching runs inside a throwaway container (``podman run --rm``) using
291
+ the same image that will be used for the agent. The patched binary is
292
+ cached on disk with flock-based reference counting.
293
+
294
+ Returns ``(patched_install, cache_entry, cache)`` on success, or
295
+ *None* if tweakcc is disabled or patching fails (graceful fallback).
296
+ """
297
+ from kanibako.bun_sea import BunSEAError, cli_js_hash
298
+ from kanibako.targets.base import AgentInstall
299
+ from kanibako.tweakcc import build_merged_config, resolve_tweakcc_config, write_merged_config
300
+ from kanibako.tweakcc_cache import TweakccCache, TweakccCacheError, config_hash
301
+
302
+ tweakcc_cfg = resolve_tweakcc_config(crab_cfg.tweakcc)
303
+ if not tweakcc_cfg.enabled:
304
+ return None
305
+
306
+ try:
307
+ merged_config = build_merged_config(tweakcc_cfg)
308
+ bin_hash = cli_js_hash(install.binary)
309
+ cfg_hash = config_hash(merged_config)
310
+
311
+ cache_dir = cache_path / "tweakcc"
312
+ cache = TweakccCache(cache_dir)
313
+ key = cache.cache_key(bin_hash, cfg_hash)
314
+
315
+ entry = cache.get(key)
316
+ if entry is None:
317
+ # Write merged config to cache dir (will be mounted into container)
318
+ config_file = cache_dir / f".config-{key}.json"
319
+ write_merged_config(merged_config, config_file)
320
+
321
+ def patch_fn(staging_dir, staging_binary):
322
+ """Run tweakcc --apply inside a throwaway container."""
323
+ cmd = [
324
+ runtime_cmd, "run", "--rm", "--network=none",
325
+ "-v", f"{staging_dir}:/work:rw",
326
+ "-v", f"{config_file}:/root/.tweakcc/config.json:ro",
327
+ "-e", f"TWEAKCC_CC_INSTALLATION_PATH=/work/{staging_binary.name}",
328
+ image,
329
+ "tweakcc", "--apply",
330
+ ]
331
+ logger.debug("Running tweakcc via container: %s", cmd)
332
+ result = subprocess.run(
333
+ cmd, capture_output=True, text=True, check=False,
334
+ )
335
+ if result.returncode != 0:
336
+ raise TweakccCacheError(
337
+ f"tweakcc container failed (exit {result.returncode}): "
338
+ f"{result.stderr.strip()}"
339
+ )
340
+
341
+ entry = cache.put(key, install.binary, patch_fn)
342
+ logger.info("Patched binary cached: %s", key)
343
+ else:
344
+ logger.info("Using cached patched binary: %s", key)
345
+
346
+ patched_install = AgentInstall(
347
+ name=install.name,
348
+ binary=entry.path,
349
+ install_dir=install.install_dir,
350
+ )
351
+ return patched_install, entry, cache
352
+
353
+ except (BunSEAError, TweakccCacheError) as exc:
354
+ logger.warning(
355
+ "tweakcc patching failed, using unpatched binary: %s", exc,
356
+ )
357
+ return None
358
+
359
+
360
+ def _parse_cli_env(cli_env: list[str] | None) -> dict[str, str]:
361
+ """Parse ``-e/--env KEY=VALUE`` items into a dict (ignores malformed ones)."""
362
+ env: dict[str, str] = {}
363
+ for item in cli_env or []:
364
+ if "=" in item:
365
+ k, v = item.split("=", 1)
366
+ env[k] = v
367
+ return env
368
+
369
+
370
+ def _run_container(
371
+ *,
372
+ project_dir: str | None,
373
+ entrypoint: str | None,
374
+ image_override: str | None,
375
+ new_session: bool,
376
+ safe_mode: bool,
377
+ resume_mode: bool,
378
+ extra_args: list[str],
379
+ no_helpers: bool = False,
380
+ no_auto_auth: bool = False,
381
+ browser: bool = False,
382
+ share_images: bool = False,
383
+ persistent: bool = False,
384
+ model_override: str | None = None,
385
+ cli_env: list[str] | None = None,
386
+ _is_retry: bool = False,
387
+ ) -> int:
388
+ config_file = config_file_path(xdg("XDG_CONFIG_HOME", ".config"))
389
+ config = load_config(config_file)
390
+
391
+ std = load_std_paths(config)
392
+
393
+ proj = resolve_any_project(std, config, project_dir, initialize=True)
394
+
395
+ # Hint about orphaned project data when initializing a new project
396
+ if proj.is_new and proj.group is not None and proj.group.is_default:
397
+ from kanibako.paths import iter_projects
398
+ for _settings, _ppath in iter_projects(std, config):
399
+ if _ppath is not None and not _ppath.is_dir():
400
+ print(
401
+ "hint: orphaned project data detected — "
402
+ "run 'kanibako box list' or use 'kanibako box migrate' "
403
+ "if you moved a project.",
404
+ file=sys.stderr,
405
+ )
406
+ break
407
+
408
+ # Load merged config (global + workset + project)
409
+ project_toml = proj.metadata_path / "project.yaml"
410
+ workset_path = (proj.group.root / "config.yaml") if proj.group is not None else None
411
+ merged = load_merged_config(
412
+ config_file,
413
+ project_toml,
414
+ workset_path=workset_path,
415
+ cli_overrides={"box_image": image_override} if image_override else None,
416
+ )
417
+
418
+ image = merged.box_image
419
+
420
+ # Persist image override for new projects so it becomes the default
421
+ if proj.is_new and image_override:
422
+ from kanibako.config import write_project_config
423
+ write_project_config(project_toml, image_override)
424
+
425
+ # Detect container runtime and ensure image is available
426
+ try:
427
+ runtime = ContainerRuntime()
428
+ except ContainerError:
429
+ print(
430
+ "Error: No container runtime found.\n"
431
+ "Install podman (https://podman.io/) or Docker, then try again.",
432
+ file=sys.stderr,
433
+ )
434
+ return 1
435
+
436
+ # Resolve the rig name to a kind + prep action, then materialize it.
437
+ # Templates BUILD their Containerfile; prefabs keep the existing
438
+ # inspect->pull->build behavior via ensure_image (non-regression).
439
+ containers_dir = std.data_path / "containers"
440
+ registry = load_registry(registry_path(std))
441
+ res = resolve_rig(image, runtime, std, merged, registry=registry)
442
+ try:
443
+ if (
444
+ res.kind == "template"
445
+ and res.containerfile is not None
446
+ and not runtime.image_exists(res.image)
447
+ ):
448
+ print(
449
+ f"Rig '{image}' isn't prepped — building...",
450
+ file=sys.stderr,
451
+ )
452
+ rc = runtime.rebuild(
453
+ res.image,
454
+ res.containerfile,
455
+ res.containerfile.parent,
456
+ build_args=None,
457
+ )
458
+ if rc != 0:
459
+ print(
460
+ f"Error: failed to build rig '{image}' "
461
+ f"(exit code {rc}).",
462
+ file=sys.stderr,
463
+ )
464
+ return 1
465
+ else:
466
+ # Prefab (or already-local template/extended): preserve the
467
+ # existing inspect->pull->build-fallback behavior exactly.
468
+ runtime.ensure_image(res.image, containers_dir)
469
+ except ContainerError as e:
470
+ print(f"Error: {e}", file=sys.stderr)
471
+ return 1
472
+ image = res.image
473
+
474
+ from kanibako.freshness import check_image_freshness
475
+ check_image_freshness(runtime, image, std.cache_path)
476
+
477
+ # Resolve target (agent plugin) and detect installation
478
+ logger = get_logger("start")
479
+ is_agent_mode = entrypoint is None
480
+ target = None
481
+ install = None
482
+ if is_agent_mode:
483
+ try:
484
+ target = resolve_target(merged.box_crab or None)
485
+ except KeyError as e:
486
+ print(
487
+ f"Error: {e}\n"
488
+ f"Run 'kanibako crab list' to see available agents, or\n"
489
+ f"'kanibako system diagnose' for a full health check.",
490
+ file=sys.stderr,
491
+ )
492
+ return 1
493
+ logger.debug("Resolved target: %s", target.display_name)
494
+ install = target.detect()
495
+ if install:
496
+ print(
497
+ f"Using host {target.display_name}: {install.binary}",
498
+ file=sys.stderr,
499
+ )
500
+ elif target.has_binary:
501
+ print(
502
+ f"Warning: {target.display_name} binary not found on host. "
503
+ f"Launching without agent.",
504
+ file=sys.stderr,
505
+ )
506
+ logger.debug("target.detect() returned None for %s", target.name)
507
+
508
+ # Load agent config
509
+ agent_id = target.name if target else "general"
510
+ crab_cfg_path = std.crabs / f"{agent_id}.yaml"
511
+ if target and not crab_cfg_path.exists():
512
+ # First-use: generate default crab config from target plugin
513
+ crab_cfg = target.generate_crab_config()
514
+ write_crab_config(crab_cfg_path, crab_cfg)
515
+ else:
516
+ crab_cfg = load_crab_config(crab_cfg_path)
517
+
518
+ # Deterministic container name for stop/cleanup
519
+ container_name = container_name_for(proj)
520
+
521
+ logger.debug("Project: %s (mode=%s)", proj.project_path, proj.mode)
522
+ logger.debug("Image: %s", image)
523
+ logger.debug("Container: %s", container_name)
524
+
525
+ # Persistent mode: reattach if already running, clean up stale containers
526
+ if persistent:
527
+ if runtime.is_running(container_name):
528
+ # Refresh credentials before reattaching
529
+ if target and proj.group_auth:
530
+ target.refresh_credentials(proj.shell_path)
531
+ return runtime.exec(
532
+ container_name, ["tmux", "attach", "-t", "kanibako"]
533
+ )
534
+ # Stale stopped container: remove before recreating
535
+ if runtime.container_exists(container_name):
536
+ runtime.rm(container_name)
537
+ # Persistent mode forces no helpers
538
+ no_helpers = True
539
+ else:
540
+ # Interactive (shell/ephemeral) mode: if a container is already
541
+ # running for this project AND we're in shell mode (entrypoint set,
542
+ # no agent), exec into it instead of erroring — matches the natural
543
+ # UX of `kanibako shell <name> -- cmd` against a live container.
544
+ if runtime.is_running(container_name) and entrypoint is not None:
545
+ exec_cmd = [entrypoint] + (extra_args or [])
546
+ # Apply per-run -e/--env vars to the exec'd process. The container's
547
+ # baseline env (env files, crab_cfg.env, KANIBAKO_NAME) was set at
548
+ # launch and is inherited by exec; without this, per-run -e vars
549
+ # would be silently dropped when the box is already running.
550
+ return runtime.exec(
551
+ container_name, exec_cmd, env=_parse_cli_env(cli_env)
552
+ )
553
+ if runtime.container_exists(container_name):
554
+ print(
555
+ "Error: A container already exists for this project.\n"
556
+ " Reattach: kanibako start\n"
557
+ " Stop it: kanibako stop",
558
+ file=sys.stderr,
559
+ )
560
+ return 1
561
+
562
+ # Concurrency lock (skip for persistent — container existence is the lock)
563
+ lock_fd = None
564
+ if not persistent:
565
+ lock_file = proj.metadata_path / ".kanibako.lock"
566
+ lock_file.parent.mkdir(parents=True, exist_ok=True)
567
+ lock_fd = open(lock_file, "w")
568
+ try:
569
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
570
+ except OSError:
571
+ print(
572
+ "Error: Another Kanibako session is already running for this project.\n"
573
+ " Stop it first: kanibako stop\n"
574
+ " Or use a shell: kanibako shell",
575
+ file=sys.stderr,
576
+ )
577
+ lock_fd.close()
578
+ return 1
579
+
580
+ # Record container name so `kanibako stop` can find it
581
+ lock_fd.write(container_name + "\n")
582
+ lock_fd.flush()
583
+
584
+ try:
585
+ # Auto-snapshot vault share-rw before launch.
586
+ if proj.enable_vault and proj.vault_rw_path.is_dir():
587
+ from kanibako.snapshots import auto_snapshot, detect_snapshot_strategy
588
+ strategy = detect_snapshot_strategy(proj.vault_rw_path)
589
+ snap = auto_snapshot(proj.vault_rw_path, strategy=strategy)
590
+ if snap:
591
+ print(f"Vault snapshot: {snap.name}", file=sys.stderr)
592
+
593
+ # Upgrade shell (add shell.d support to existing shells).
594
+ _upgrade_shell(proj.shell_path)
595
+
596
+ # Shell directory hygiene: remove waste files, compress old logs.
597
+ from kanibako.hygiene import cleanup_shell_dir
598
+ hygiene_actions = cleanup_shell_dir(proj.shell_path)
599
+ if hygiene_actions:
600
+ for action in hygiene_actions:
601
+ logger.info(action)
602
+
603
+ # Template application + agent init for new projects.
604
+ if proj.is_new and target:
605
+ from kanibako.templates import apply_shell_template
606
+ templates_base = std.templates
607
+ # Ensure the agent-specific template variant directory exists.
608
+ (templates_base / target.name / crab_cfg.shell).mkdir(parents=True, exist_ok=True)
609
+ apply_shell_template(proj.shell_path, templates_base, target.name, crab_cfg.shell)
610
+ target.init_home(proj.shell_path, group_auth=proj.group_auth)
611
+
612
+ # Merge layered instruction files (base + template + user).
613
+ instr_files = target.instruction_files()
614
+ if instr_files:
615
+ from kanibako.instructions import merge_instruction_files
616
+ merge_instruction_files(
617
+ shell_path=proj.shell_path,
618
+ config_dir_name=target.config_dir_name,
619
+ instruction_files=instr_files,
620
+ templates_base=templates_base,
621
+ agent_name=target.name,
622
+ template_name=crab_cfg.shell,
623
+ )
624
+
625
+ # Copy-once-at-init seeds (additive; overlays templates). target may be
626
+ # None (no agent) — seeds can still come from config levels.
627
+ if proj.is_new:
628
+ _apply_init_seeds(
629
+ std=std, proj=proj, crab_name=agent_id, target=target,
630
+ global_config_path=config_file, project_toml=project_toml,
631
+ workset_config_path=workset_path, crab_config_path=crab_cfg_path,
632
+ logger=logger,
633
+ )
634
+
635
+ # Automated OAuth refresh (before interactive check_auth)
636
+ if (
637
+ target
638
+ and install
639
+ and proj.group_auth
640
+ and not no_auto_auth
641
+ and target.name == "claude"
642
+ ):
643
+ try:
644
+ from kanibako.auth_browser import auto_refresh_auth
645
+
646
+ auto_result = auto_refresh_auth(
647
+ str(install.binary), std.data_path
648
+ )
649
+ if auto_result.success:
650
+ logger.info("Auto-auth succeeded")
651
+ else:
652
+ logger.debug("Auto-auth skipped: %s", auto_result.error)
653
+ except Exception as exc:
654
+ logger.debug("Auto-auth failed: %s", exc)
655
+
656
+ # Pre-launch auth check (skip for distinct auth — creds live in project)
657
+ if target and install and proj.group_auth:
658
+ if not target.check_auth():
659
+ print(
660
+ "Error: Authentication failed.\n"
661
+ " Re-authenticate: kanibako crab reauth\n"
662
+ " Skip agent: kanibako shell",
663
+ file=sys.stderr,
664
+ )
665
+ return 1
666
+
667
+ # Credential refresh via target (skip for distinct auth)
668
+ if target and proj.group_auth:
669
+ target.refresh_credentials(proj.shell_path)
670
+
671
+ # tweakcc: patch agent binary if enabled
672
+ tweakcc_entry = None
673
+ tweakcc_cache_obj = None
674
+ if target and install and crab_cfg.tweakcc:
675
+ result = _apply_tweakcc(
676
+ install, crab_cfg, std.cache_path, image, runtime.cmd, logger,
677
+ )
678
+ if result:
679
+ install, tweakcc_entry, tweakcc_cache_obj = result
680
+
681
+ # Build CLI args via target, merging crab run_args and state
682
+ if target:
683
+ effective_state = _build_effective_state(
684
+ target,
685
+ crab_cfg,
686
+ project_toml,
687
+ global_config_path=config_file,
688
+ workset_config_path=workset_path,
689
+ )
690
+ # Apply model override from -M/--model flag
691
+ if model_override:
692
+ effective_state["model"] = model_override
693
+ state_args, state_env = target.apply_state(effective_state)
694
+ all_extra = list(crab_cfg.run_args) + list(extra_args)
695
+ cli_args = target.build_cli_args(
696
+ safe_mode=safe_mode,
697
+ resume_mode=resume_mode,
698
+ new_session=new_session,
699
+ is_new_project=proj.is_new,
700
+ extra_args=all_extra,
701
+ )
702
+ cli_args.extend(state_args)
703
+ else:
704
+ state_env = {}
705
+ cli_args = list(extra_args)
706
+
707
+ # Build extra mounts from target binary detection
708
+ extra_mounts = []
709
+ if target and install:
710
+ binary_mnts = target.binary_mounts(install)
711
+ if not binary_mnts:
712
+ print(
713
+ f"Error: {target.display_name} binary detected at "
714
+ f"{install.binary} but mount sources are missing.\n"
715
+ f" binary: {install.binary} "
716
+ f"({'exists' if install.binary.exists() else 'MISSING'})\n"
717
+ f" install_dir: {install.install_dir} "
718
+ f"({'exists' if install.install_dir.exists() else 'MISSING'})\n"
719
+ f"The container would launch without the agent binary.",
720
+ file=sys.stderr,
721
+ )
722
+ return 1
723
+ _sync_binary_symlink(proj.shell_path, install, binary_mnts, logger)
724
+ extra_mounts.extend(binary_mnts)
725
+
726
+ # kanibako CLI bind-mount (package + entry script)
727
+ kanibako_mnts = _kanibako_mounts()
728
+ extra_mounts.extend(kanibako_mnts)
729
+
730
+ # Shared cache mounts (global, lazy — only mount if dir exists)
731
+ if proj.global_shared_path:
732
+ from kanibako.targets.base import Mount
733
+ for cache_name, container_rel in merged.shared_caches.items():
734
+ host_dir = proj.global_shared_path / cache_name
735
+ if host_dir.is_dir():
736
+ extra_mounts.append(Mount(
737
+ source=host_dir,
738
+ destination=f"/home/agent/{container_rel}",
739
+ options="Z,U",
740
+ ))
741
+
742
+ # Agent-level shared cache mounts (lazy — only mount if dir exists)
743
+ if proj.local_shared_path and crab_cfg.shared_caches:
744
+ from kanibako.targets.base import Mount as _Mount
745
+ for cache_name, container_rel in crab_cfg.shared_caches.items():
746
+ host_dir = proj.local_shared_path / agent_id / cache_name
747
+ if host_dir.is_dir():
748
+ extra_mounts.append(_Mount(
749
+ source=host_dir,
750
+ destination=f"/home/agent/{container_rel}",
751
+ options="Z,U",
752
+ ))
753
+
754
+ # Resource scope mounts (SHARED / SEEDED from target.resource_mappings())
755
+ if target and proj.global_shared_path:
756
+ resource_mounts = _build_resource_mounts(proj, target, agent_id)
757
+ extra_mounts.extend(resource_mounts)
758
+
759
+ # Scoped shares (settings-framework {scope}.path.share_{ro,rw}.*).
760
+ # Additive: empty config → no mounts → no behavior change.
761
+ share_mounts = _build_share_mounts(
762
+ std=std,
763
+ proj=proj,
764
+ crab_name=agent_id,
765
+ global_config_path=config_file,
766
+ project_toml=project_toml,
767
+ workset_config_path=workset_path,
768
+ crab_config_path=crab_cfg_path,
769
+ target=target,
770
+ )
771
+ extra_mounts.extend(share_mounts)
772
+
773
+ # Image sharing: mount host image storage read-only into child.
774
+ if share_images or merged.box_share_images:
775
+ from kanibako.image_sharing import build_image_sharing_mounts
776
+ staging = proj.metadata_path / ".image-sharing"
777
+ img_mounts = build_image_sharing_mounts(
778
+ runtime.cmd, staging,
779
+ )
780
+ if img_mounts:
781
+ extra_mounts.extend(img_mounts)
782
+ logger.info("Image sharing enabled: %d mounts added", len(img_mounts))
783
+ else:
784
+ print(
785
+ "Warning: --share-images enabled but host image storage "
786
+ "could not be detected. Continuing without image sharing.",
787
+ file=sys.stderr,
788
+ )
789
+
790
+ # Peer communication: mount shared comms directory.
791
+ from kanibako.targets.base import Mount as _CMount
792
+ comms_path = std.comms
793
+ comms_path.mkdir(parents=True, exist_ok=True)
794
+ if proj.name:
795
+ mailbox = comms_path / "mailbox" / proj.name
796
+ mailbox.mkdir(parents=True, exist_ok=True)
797
+ broadcast = comms_path / "broadcast.log"
798
+ if not broadcast.exists():
799
+ broadcast.touch()
800
+ _rotate_file(broadcast)
801
+ extra_mounts.append(
802
+ _CMount(comms_path, "/home/agent/comms", "Z,U"),
803
+ )
804
+
805
+ # Read environment variables, accumulating across config levels with
806
+ # the settings-framework precedence (low->high): system < crab <
807
+ # workset < box. Target-derived state env and per-run CLI -e env stay
808
+ # above all config levels.
809
+ global_env_path = std.data_path / "env"
810
+ project_env_path = proj.metadata_path / "env"
811
+ # Workset-level env applies only for a named (non-default) workset
812
+ # group; the default group's tier is already the system env.
813
+ workset_env_path = (
814
+ proj.group.root / "env"
815
+ if (proj.group is not None and not proj.group.is_default)
816
+ else None
817
+ )
818
+ container_env = _build_config_env(
819
+ global_env_path, crab_cfg.env, workset_env_path, project_env_path,
820
+ )
821
+ container_env.update(state_env) # target-derived state env
822
+
823
+ # Merge per-run -e/--env KEY=VALUE vars (highest priority).
824
+ container_env.update(_parse_cli_env(cli_env))
825
+
826
+ # Disable Claude Code telemetry inside containers.
827
+ if target and target.name == "claude":
828
+ container_env.setdefault(
829
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1",
830
+ )
831
+
832
+ # Inject instance identity for peer communication.
833
+ if proj.name:
834
+ container_env["KANIBAKO_NAME"] = proj.name
835
+
836
+ # Helper hub: start listener before director, mount socket
837
+ hub = None
838
+ helpers_enabled = not no_helpers and merged.allow_helpers
839
+ if helpers_enabled:
840
+ from kanibako.helper_listener import HelperContext, HelperHub, MessageLog
841
+ from kanibako.targets.base import Mount as _HMount
842
+
843
+ # Socket must live in a short path to stay under the 108-byte
844
+ # AF_UNIX limit. /run/user/$UID is the XDG runtime dir.
845
+ _uid = os.getuid()
846
+ _run_base = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{_uid}"))
847
+ _run_dir = _run_base / "kanibako"
848
+ try:
849
+ _run_dir.mkdir(parents=True, exist_ok=True)
850
+ except OSError:
851
+ # Fallback if /run/user/$UID is not writable
852
+ _run_dir = Path(f"/tmp/kanibako-{_uid}")
853
+ _run_dir.mkdir(parents=True, exist_ok=True)
854
+ _sock_id = proj.name if proj.name else short_hash(proj.project_hash)
855
+ socket_path = _run_dir / f"{_sock_id}.sock"
856
+ validate_socket_path(socket_path)
857
+ _log_id = proj.name if proj.name else short_hash(proj.project_hash)
858
+ log_dir = std.data_path / "logs" / _log_id
859
+ log_dir.mkdir(parents=True, exist_ok=True)
860
+ log_path = log_dir / "helper-messages.jsonl"
861
+
862
+ # Ensure helpers/ dir exists in shell_path
863
+ helpers_dir = proj.shell_path / "helpers"
864
+ helpers_dir.mkdir(exist_ok=True)
865
+
866
+ # Build context for helper container launches
867
+ binary_mounts = list(kanibako_mnts)
868
+ if target and install:
869
+ binary_mounts.extend(target.binary_mounts(install))
870
+
871
+ # Share tweakcc cache with helpers so they reuse patched binaries
872
+ if tweakcc_entry is not None:
873
+ _tweakcc_cache_dir = std.cache_path / "tweakcc"
874
+ if _tweakcc_cache_dir.is_dir():
875
+ binary_mounts.append(_HMount(
876
+ source=_tweakcc_cache_dir,
877
+ destination=str(_tweakcc_cache_dir),
878
+ options="ro",
879
+ ))
880
+
881
+ helper_ctx = HelperContext(
882
+ runtime=runtime,
883
+ image=image,
884
+ container_name_prefix=container_name,
885
+ shell_path=proj.shell_path,
886
+ helpers_dir=helpers_dir,
887
+ socket_path=socket_path,
888
+ binary_mounts=binary_mounts,
889
+ env=container_env,
890
+ entrypoint=entrypoint,
891
+ default_entrypoint=target.default_entrypoint if target else None,
892
+ project_path=proj.project_path,
893
+ data_path=std.data_path,
894
+ boxes=std.boxes,
895
+ )
896
+
897
+ msg_log = MessageLog(log_path)
898
+ hub = HelperHub()
899
+ hub.start(socket_path, helper_ctx, log=msg_log)
900
+
901
+ # Mount the socket into the container (only if hub started)
902
+ kanibako_dir = proj.shell_path / ".local" / "state" / "kanibako"
903
+ kanibako_dir.mkdir(parents=True, exist_ok=True)
904
+ if socket_path.exists():
905
+ extra_mounts.append(_HMount(
906
+ source=socket_path,
907
+ destination="/home/agent/.local/state/kanibako/helper.sock",
908
+ options="",
909
+ ))
910
+
911
+ # Mount helper-messages.jsonl for log command inside container
912
+ if log_path.exists():
913
+ extra_mounts.append(_HMount(
914
+ source=log_path,
915
+ destination="/home/agent/.local/state/kanibako/helper-messages.jsonl",
916
+ options="ro",
917
+ ))
918
+
919
+ # Pre-launch validation: warn about missing mount sources.
920
+ _validate_mounts(extra_mounts, logger)
921
+
922
+ # Browser sidecar: on-demand headless Chrome for agent web access
923
+ browser_sidecar = None
924
+ if browser:
925
+ try:
926
+ from kanibako.browser_sidecar import (
927
+ BrowserSidecar,
928
+ ws_endpoint_for_container,
929
+ )
930
+
931
+ sidecar_name = f"{container_name}-browser"
932
+ browser_sidecar = BrowserSidecar(
933
+ runtime=runtime,
934
+ container_name=sidecar_name,
935
+ )
936
+ ws_url = browser_sidecar.start()
937
+ container_ws = ws_endpoint_for_container(ws_url)
938
+ container_env["BROWSER_WS_ENDPOINT"] = container_ws
939
+ logger.info("Browser sidecar started: %s", container_ws)
940
+ except Exception as exc:
941
+ logger.warning("Browser sidecar failed to start: %s", exc)
942
+ browser_sidecar = None
943
+
944
+ # Set agent entrypoint if not explicitly overridden.
945
+ if not entrypoint and target:
946
+ entrypoint = target.default_entrypoint
947
+
948
+ # Persistent mode: wrap command with tmux
949
+ if persistent:
950
+ inner_cmd = entrypoint or "/bin/bash"
951
+ tmux_args = ["new-session", "-s", "kanibako", "--", inner_cmd]
952
+ if cli_args:
953
+ tmux_args.extend(cli_args)
954
+ entrypoint = "tmux"
955
+ cli_args = tmux_args
956
+
957
+ try:
958
+ # Run the container
959
+ rc = runtime.run(
960
+ image,
961
+ shell_path=proj.shell_path,
962
+ project_path=proj.project_path,
963
+ vault_ro_path=proj.vault_ro_path,
964
+ vault_rw_path=proj.vault_rw_path,
965
+ extra_mounts=extra_mounts or None,
966
+ vault_tmpfs=(proj.group is not None and proj.group.is_default),
967
+ enable_vault=proj.enable_vault,
968
+ env=container_env,
969
+ name=container_name,
970
+ entrypoint=entrypoint,
971
+ cli_args=cli_args or None,
972
+ detach=persistent,
973
+ )
974
+ finally:
975
+ # Stop helper hub after director exits
976
+ if hub is not None:
977
+ hub.stop()
978
+ # Release tweakcc cache entry (shared lock)
979
+ if tweakcc_entry is not None and tweakcc_cache_obj is not None:
980
+ tweakcc_cache_obj.release(tweakcc_entry)
981
+ # Stop browser sidecar
982
+ if browser_sidecar is not None:
983
+ try:
984
+ browser_sidecar.stop()
985
+ except Exception as exc:
986
+ logger.debug("Browser sidecar cleanup: %s", exc)
987
+
988
+ if persistent:
989
+ # Wait briefly for the detached container to start, then verify
990
+ # it's actually running before trying to exec into it.
991
+ import time
992
+ for _attempt in range(10):
993
+ if runtime.is_running(container_name):
994
+ break
995
+ time.sleep(0.3)
996
+ else:
997
+ # Container never started or exited immediately. If the
998
+ # target says this is recoverable (e.g. "no conversation
999
+ # to continue"), retry with a fresh session before bailing.
1000
+ logs = _container_logs(runtime, container_name)
1001
+ if logs:
1002
+ print(logs, file=sys.stderr)
1003
+ if (
1004
+ target
1005
+ and not new_session
1006
+ and not _is_retry
1007
+ and logs
1008
+ and target.should_retry_new_session(logs)
1009
+ ):
1010
+ print(
1011
+ "Restarting with a new session.",
1012
+ file=sys.stderr,
1013
+ )
1014
+ runtime.rm(container_name)
1015
+ return _run_container(
1016
+ project_dir=project_dir,
1017
+ entrypoint=None,
1018
+ image_override=image_override,
1019
+ new_session=True,
1020
+ safe_mode=safe_mode,
1021
+ resume_mode=False,
1022
+ extra_args=extra_args,
1023
+ no_helpers=no_helpers,
1024
+ no_auto_auth=no_auto_auth,
1025
+ browser=browser,
1026
+ share_images=share_images,
1027
+ persistent=persistent,
1028
+ model_override=model_override,
1029
+ cli_env=cli_env,
1030
+ _is_retry=True,
1031
+ )
1032
+ print(
1033
+ "Error: Container exited before session could attach.\n"
1034
+ "Check the logs above, or run 'kanibako system diagnose'.",
1035
+ file=sys.stderr,
1036
+ )
1037
+ return 1
1038
+
1039
+ # Attach to the new tmux session. The container may not be
1040
+ # fully ready for exec even though is_running() returned True
1041
+ # (podman race: "container state improper"). Retry a few times.
1042
+ _max_exec_attempts = 5
1043
+ for _exec_attempt in range(1, _max_exec_attempts + 1):
1044
+ rc = runtime.exec(
1045
+ container_name, ["tmux", "attach", "-t", "kanibako"]
1046
+ )
1047
+ if rc == 0:
1048
+ break
1049
+ # Non-zero exit — check if the container is still alive.
1050
+ if not runtime.is_running(container_name):
1051
+ # Container died; fall through to the log-showing code.
1052
+ break
1053
+ # Container still running but exec failed (transient race).
1054
+ if _exec_attempt < _max_exec_attempts:
1055
+ print(
1056
+ f"Warning: container not ready for exec "
1057
+ f"(attempt {_exec_attempt}/{_max_exec_attempts}), "
1058
+ f"retrying...",
1059
+ file=sys.stderr,
1060
+ )
1061
+ time.sleep(0.5)
1062
+ # If agent exited, show container logs so the user can
1063
+ # see why (tmux swallows output on exit).
1064
+ if not runtime.is_running(container_name):
1065
+ logs = _container_logs(runtime, container_name)
1066
+ if logs:
1067
+ print(logs, file=sys.stderr)
1068
+ # Auto-retry as new session if the target says so
1069
+ # (once only — _is_retry prevents loops).
1070
+ if (
1071
+ target
1072
+ and not new_session
1073
+ and not _is_retry
1074
+ and target.should_retry_new_session(logs)
1075
+ ):
1076
+ print(
1077
+ "Restarting with a new session.",
1078
+ file=sys.stderr,
1079
+ )
1080
+ runtime.rm(container_name)
1081
+ return _run_container(
1082
+ project_dir=project_dir,
1083
+ entrypoint=None,
1084
+ image_override=image_override,
1085
+ new_session=True,
1086
+ safe_mode=safe_mode,
1087
+ resume_mode=False,
1088
+ extra_args=extra_args,
1089
+ no_helpers=no_helpers,
1090
+ no_auto_auth=no_auto_auth,
1091
+ browser=browser,
1092
+ share_images=share_images,
1093
+ persistent=persistent,
1094
+ model_override=model_override,
1095
+ cli_env=cli_env,
1096
+ _is_retry=True,
1097
+ )
1098
+ else:
1099
+ # Write back refreshed credentials via target (skip for distinct auth)
1100
+ if target and proj.group_auth:
1101
+ target.writeback_credentials(proj.shell_path)
1102
+
1103
+ # Hint when agent exits non-zero and --continue/--resume was used
1104
+ if rc != 0 and is_agent_mode and not new_session:
1105
+ print(
1106
+ "hint: if the agent exited because there was no conversation "
1107
+ "to continue, use 'kanibako start -N' to start fresh.",
1108
+ file=sys.stderr,
1109
+ )
1110
+
1111
+ return rc
1112
+
1113
+ finally:
1114
+ if lock_fd is not None:
1115
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
1116
+ lock_fd.close()
1117
+
1118
+
1119
+ def _build_config_env(
1120
+ global_env_path,
1121
+ crab_env: dict[str, str],
1122
+ workset_env_path,
1123
+ project_env_path,
1124
+ ) -> dict[str, str]:
1125
+ """Layer config-level env vars, low->high: system < crab < workset < box.
1126
+
1127
+ Shared between container launch (start) and ``box config --effective`` so
1128
+ the resolved config-env matches exactly. Runtime-only layers (target state
1129
+ env, per-run ``-e``) are applied by the caller ON TOP of this and are NOT
1130
+ config, so they are excluded here.
1131
+ """
1132
+ from kanibako.shellenv import read_env_file
1133
+ env: dict[str, str] = {}
1134
+ env.update(read_env_file(global_env_path)) # system
1135
+ env.update(crab_env) # crab
1136
+ if workset_env_path is not None:
1137
+ env.update(read_env_file(workset_env_path)) # workset
1138
+ env.update(read_env_file(project_env_path)) # box (highest config level)
1139
+ return env
1140
+
1141
+
1142
+ def _build_effective_state(
1143
+ target,
1144
+ crab_cfg,
1145
+ project_toml,
1146
+ *,
1147
+ global_config_path,
1148
+ workset_config_path=None,
1149
+ ) -> dict[str, str]:
1150
+ """Resolve effective crab-state via the settings precedence walk.
1151
+
1152
+ Walks four levels MOST-SPECIFIC-FIRST — box > workset > crab > system —
1153
+ with the target's declared defaults as a FLOOR (the system level's declared
1154
+ defaults). Sources for each level's ``[crab]`` table:
1155
+
1156
+ * **box** — ``[crab]`` in project.yaml
1157
+ * **workset** — ``[crab]`` in the workset's config.yaml (if any)
1158
+ * **crab** — the crab config's own state dict
1159
+ * **system** — ``[crab]`` in the global kanibako.yaml
1160
+ * **floor** — target ``setting_descriptors()`` defaults
1161
+
1162
+ Explicit set values beat all declared defaults; the most-specific level
1163
+ wins; an explicit ``""`` is terminal (no fall-through to the floor).
1164
+ Undeclared keys set anywhere (e.g. ``start_mode``) are passed through.
1165
+
1166
+ Values are used verbatim — no ``@``-ref / ``$var`` / ``~`` expansion.
1167
+
1168
+ With no system/workset ``[crab]`` config (the common case) the walk reduces
1169
+ to box > crab > floor, i.e. project override > crab state > target default —
1170
+ identical to the prior two-source merge.
1171
+ """
1172
+ from kanibako.config import read_crab_settings
1173
+ from kanibako.settings_resolve import (
1174
+ LevelView,
1175
+ ResolveCtx,
1176
+ SettingsError,
1177
+ _Unset,
1178
+ resolve_value,
1179
+ )
1180
+
1181
+ descriptors = target.setting_descriptors()
1182
+ if not descriptors:
1183
+ return dict(crab_cfg.state)
1184
+
1185
+ def _read(path) -> dict[str, str]:
1186
+ if not path:
1187
+ return {}
1188
+ try:
1189
+ if not path.exists():
1190
+ return {}
1191
+ return read_crab_settings(path)
1192
+ except Exception:
1193
+ return {}
1194
+
1195
+ # Gather per-level [crab] leaf values.
1196
+ box_vals = _read(project_toml)
1197
+ ws_vals = _read(workset_config_path)
1198
+ crab_vals = dict(crab_cfg.state)
1199
+ sys_vals = _read(global_config_path)
1200
+ floor = {d.key: d.default for d in descriptors}
1201
+
1202
+ # Most-specific first; the floor is the system level's declared defaults.
1203
+ levels = [
1204
+ LevelView("box", box_vals),
1205
+ LevelView("workset", ws_vals),
1206
+ LevelView("crab", crab_vals),
1207
+ LevelView("system", sys_vals, defaults=floor),
1208
+ ]
1209
+
1210
+ keys = (
1211
+ set(floor)
1212
+ | set(box_vals)
1213
+ | set(ws_vals)
1214
+ | set(crab_vals)
1215
+ | set(sys_vals)
1216
+ )
1217
+
1218
+ ctx = ResolveCtx(
1219
+ crab_name=target.name,
1220
+ workset_name=None,
1221
+ host_home=str(Path.home()),
1222
+ xdg={},
1223
+ )
1224
+
1225
+ def _no_lookup(ref, chain):
1226
+ raise SettingsError(f"@-refs not supported in crab settings: {ref}")
1227
+
1228
+ effective: dict[str, str] = {}
1229
+ for key in keys:
1230
+ rv = resolve_value(key, levels=levels, ctx=ctx, lookup=_no_lookup)
1231
+ if not isinstance(rv, _Unset):
1232
+ effective[key] = rv.value
1233
+
1234
+ return effective
1235
+
1236
+
1237
+ def _apply_init_seeds(
1238
+ *,
1239
+ std,
1240
+ proj,
1241
+ crab_name: str,
1242
+ target=None,
1243
+ global_config_path,
1244
+ project_toml,
1245
+ workset_config_path,
1246
+ crab_config_path,
1247
+ logger,
1248
+ ) -> None:
1249
+ """Copy configured copy-once-at-init seeds into the new project's shell dir.
1250
+
1251
+ ADDITIVE: with no seed config and no target default seeds, copies nothing.
1252
+ Resolves {scope}.path.seeded.* across the 4 levels (target.default_seeds()
1253
+ as the crab level's declared defaults), translates each SeedPair's
1254
+ guest_dest (/home/agent/X) to a host path under proj.shell_path, and copies
1255
+ host_src -> that path once (dir -> copytree dirs_exist_ok; file -> copy2).
1256
+ """
1257
+ import shutil
1258
+
1259
+ from kanibako.config import read_seeds
1260
+ from kanibako.settings_resolve import (
1261
+ GUEST_HOME,
1262
+ LevelView,
1263
+ ResolveCtx,
1264
+ SettingsError,
1265
+ )
1266
+ from kanibako.settings_seeds import resolve_seeds
1267
+
1268
+ default_seeds = target.default_seeds() if target is not None else {}
1269
+
1270
+ # Four precedence levels, most-specific first; crab carries the defaults.
1271
+ levels = [
1272
+ LevelView("box", read_seeds(project_toml)),
1273
+ LevelView("workset", read_seeds(workset_config_path)),
1274
+ LevelView("crab", read_seeds(crab_config_path), defaults=default_seeds),
1275
+ LevelView("system", read_seeds(global_config_path)),
1276
+ ]
1277
+
1278
+ workset_name = (
1279
+ proj.group.name
1280
+ if (proj.group is not None and not proj.group.is_default)
1281
+ else None
1282
+ )
1283
+ ctx = ResolveCtx(
1284
+ crab_name=crab_name,
1285
+ workset_name=workset_name,
1286
+ host_home=str(Path.home()),
1287
+ xdg={"XDG_DATA_HOME": str(std.data_home)},
1288
+ )
1289
+
1290
+ resolved_sys = {
1291
+ "system.path.data": str(std.data_path),
1292
+ "system.path.boxes": str(std.boxes),
1293
+ "system.path.crabs": str(std.crabs),
1294
+ "system.path.comms": str(std.comms),
1295
+ "system.path.templates": str(std.templates),
1296
+ "system.path.ws_hints": str(std.ws_hints),
1297
+ "system.path.share_ro": str(std.share_ro),
1298
+ "system.path.share_rw": str(std.share_rw),
1299
+ }
1300
+
1301
+ def _lookup(ref, chain):
1302
+ if ref in resolved_sys:
1303
+ return resolved_sys[ref]
1304
+ raise SettingsError(f"Unresolvable @-reference in seed value: {ref}")
1305
+
1306
+ seeds = resolve_seeds(levels=levels, ctx=ctx, lookup=_lookup)
1307
+
1308
+ for seed in seeds:
1309
+ gd = seed.guest_dest.rstrip("/")
1310
+ if gd == GUEST_HOME:
1311
+ dest = proj.shell_path
1312
+ elif gd.startswith(GUEST_HOME + "/"):
1313
+ rel = gd[len(GUEST_HOME) + 1:]
1314
+ dest = proj.shell_path / rel
1315
+ else:
1316
+ logger.warning(
1317
+ "seed %s: guest_dest %r is outside %s; skipping",
1318
+ seed.name, seed.guest_dest, GUEST_HOME,
1319
+ )
1320
+ continue
1321
+ src = Path(seed.host_src)
1322
+ if not src.exists():
1323
+ logger.warning(
1324
+ "seed %s: host_src %r does not exist; skipping",
1325
+ seed.name, seed.host_src,
1326
+ )
1327
+ continue
1328
+ if src.is_dir():
1329
+ dest.mkdir(parents=True, exist_ok=True)
1330
+ shutil.copytree(str(src), str(dest), dirs_exist_ok=True)
1331
+ else:
1332
+ dest.parent.mkdir(parents=True, exist_ok=True)
1333
+ shutil.copy2(str(src), str(dest))
1334
+
1335
+
1336
+ def _build_share_mounts(
1337
+ *,
1338
+ std,
1339
+ proj,
1340
+ crab_name: str,
1341
+ global_config_path,
1342
+ project_toml,
1343
+ workset_config_path,
1344
+ crab_config_path,
1345
+ target=None,
1346
+ ) -> list:
1347
+ """Resolve scoped-share config ({scope}.path.share_{ro,rw}.*) into Mounts.
1348
+
1349
+ ADDITIVE: with no share keys configured (and no target default shares),
1350
+ returns []. Reads each level's set share keys from its config file; the
1351
+ KEY's scope sets the source root + mode, the LEVEL where set decides
1352
+ precedence (a box can suppress an inherited system share with a terminal "").
1353
+
1354
+ *target*'s ``default_shares()`` (if a target is given) are injected as the
1355
+ CRAB level's *declared defaults*: they mount unless overridden/suppressed at
1356
+ a more-specific level. After resolution, host source directories for any
1357
+ read-write share are created best-effort (mirrors the old SHARED-mount
1358
+ behavior) so podman does not stub them; a bad source never crashes launch.
1359
+ """
1360
+ from kanibako.config import read_shares
1361
+ from kanibako.settings_resolve import LevelView, ResolveCtx, SettingsError
1362
+ from kanibako.settings_shares import resolve_shares
1363
+
1364
+ default_shares = target.default_shares() if target is not None else {}
1365
+
1366
+ # Four precedence levels, most-specific first.
1367
+ levels = [
1368
+ LevelView("box", read_shares(project_toml)),
1369
+ LevelView("workset", read_shares(workset_config_path)),
1370
+ LevelView("crab", read_shares(crab_config_path), defaults=default_shares),
1371
+ LevelView("system", read_shares(global_config_path)),
1372
+ ]
1373
+
1374
+ # Source roots per scope group (concrete host paths → expand_expr verbatim).
1375
+ crab_share_root = str(std.crabs / crab_name / "share")
1376
+ scope_roots = {
1377
+ "system.path.share_ro": str(std.share_ro),
1378
+ "system.path.share_rw": str(std.share_rw),
1379
+ "crab.path.share_ro": crab_share_root,
1380
+ "crab.path.share_rw": crab_share_root,
1381
+ }
1382
+ if proj.group is not None and not proj.group.is_default:
1383
+ ws_root = str(proj.group.root)
1384
+ scope_roots["workset.path.share_ro"] = ws_root
1385
+ scope_roots["workset.path.share_rw"] = ws_root
1386
+ # box scope: arbitrary host path, NO root → omit (host_src used as-is).
1387
+
1388
+ workset_name = (
1389
+ proj.group.name
1390
+ if (proj.group is not None and not proj.group.is_default)
1391
+ else None
1392
+ )
1393
+ ctx = ResolveCtx(
1394
+ crab_name=crab_name,
1395
+ workset_name=workset_name,
1396
+ host_home=str(Path.home()),
1397
+ xdg={"XDG_DATA_HOME": str(std.data_home)},
1398
+ )
1399
+
1400
+ # Share VALUES may reference the resolved system path tier via @-refs.
1401
+ resolved_sys = {
1402
+ "system.path.data": str(std.data_path),
1403
+ "system.path.boxes": str(std.boxes),
1404
+ "system.path.crabs": str(std.crabs),
1405
+ "system.path.comms": str(std.comms),
1406
+ "system.path.templates": str(std.templates),
1407
+ "system.path.ws_hints": str(std.ws_hints),
1408
+ "system.path.share_ro": str(std.share_ro),
1409
+ "system.path.share_rw": str(std.share_rw),
1410
+ }
1411
+
1412
+ def _lookup(ref, chain):
1413
+ if ref in resolved_sys:
1414
+ return resolved_sys[ref]
1415
+ raise SettingsError(f"Unresolvable @-reference in share value: {ref}")
1416
+
1417
+ mounts = resolve_shares(
1418
+ levels=levels, ctx=ctx, lookup=_lookup, scope_roots=scope_roots
1419
+ )
1420
+ for m in mounts:
1421
+ if m.options != "ro": # rw share: create the host source dir if absent
1422
+ try:
1423
+ m.source.mkdir(parents=True, exist_ok=True)
1424
+ except OSError:
1425
+ pass # best-effort; podman will surface a genuinely bad source
1426
+ return mounts
1427
+
1428
+
1429
+ def _kanibako_mounts():
1430
+ """Build bind mounts for the kanibako CLI inside containers.
1431
+
1432
+ Returns two mounts:
1433
+ 1. Package dir → /opt/kanibako/kanibako/ (ro)
1434
+ 2. Entry script → /home/agent/.local/bin/kanibako (ro)
1435
+ """
1436
+ import importlib.resources
1437
+
1438
+ import kanibako
1439
+ from kanibako.targets.base import Mount
1440
+
1441
+ pkg_dir = Path(kanibako.__file__).parent
1442
+
1443
+ entry_ref = importlib.resources.files("kanibako.scripts").joinpath("kanibako-entry")
1444
+ entry_path = Path(str(entry_ref))
1445
+
1446
+ return [
1447
+ Mount(pkg_dir, "/opt/kanibako/kanibako", "ro"),
1448
+ Mount(entry_path, "/home/agent/.local/bin/kanibako", "ro"),
1449
+ ]
1450
+
1451
+
1452
+ def _build_resource_mounts(proj, target, agent_id: str):
1453
+ """Build bind mounts from target resource_mappings() and per-project overrides.
1454
+
1455
+ - SHARED: mount shared dir over ``/home/agent/{config_dir}/{path}`` (read-write).
1456
+ - SEEDED: on first init, copy from shared to project-local; then no extra mount.
1457
+ - PROJECT: no extra mount (already in shell_path).
1458
+ """
1459
+ import shutil
1460
+
1461
+ from kanibako.config import read_resource_overrides
1462
+ from kanibako.targets.base import Mount, ResourceScope
1463
+
1464
+ mappings = target.resource_mappings()
1465
+ if not mappings:
1466
+ return []
1467
+
1468
+ shared_base = proj.global_shared_path
1469
+ if not shared_base:
1470
+ return []
1471
+
1472
+ config_dir = target.config_dir_name
1473
+
1474
+ project_toml = proj.metadata_path / "project.yaml"
1475
+ try:
1476
+ overrides = read_resource_overrides(project_toml)
1477
+ except Exception:
1478
+ overrides = {}
1479
+
1480
+ mounts = []
1481
+ for mapping in mappings:
1482
+ # Apply per-project override if present.
1483
+ scope_str = overrides.get(mapping.path)
1484
+ scope = ResourceScope(scope_str) if scope_str else mapping.scope
1485
+
1486
+ if scope == ResourceScope.SHARED:
1487
+ shared_path = shared_base / agent_id / mapping.path
1488
+ if mapping.path.endswith("/"):
1489
+ shared_path.mkdir(parents=True, exist_ok=True)
1490
+ else:
1491
+ # File resource: create parent dir and touch the file.
1492
+ shared_path.parent.mkdir(parents=True, exist_ok=True)
1493
+ if not shared_path.exists():
1494
+ shared_path.touch()
1495
+ mounts.append(Mount(
1496
+ source=shared_path,
1497
+ destination=f"/home/agent/{config_dir}/{mapping.path}",
1498
+ options="Z,U",
1499
+ ))
1500
+ elif scope == ResourceScope.SEEDED:
1501
+ local = proj.shell_path / config_dir / mapping.path
1502
+ if not local.exists():
1503
+ src = shared_base / agent_id / mapping.path
1504
+ if src.exists():
1505
+ if src.is_dir():
1506
+ shutil.copytree(str(src), str(local))
1507
+ else:
1508
+ local.parent.mkdir(parents=True, exist_ok=True)
1509
+ shutil.copy2(str(src), str(local))
1510
+ # PROJECT scope: no extra mount needed.
1511
+
1512
+ return mounts
1513
+
1514
+
1515
+ # AF_UNIX sun_path limit (108 on Linux, 104 on macOS).
1516
+ _UNIX_SOCKET_PATH_LIMIT = 104
1517
+
1518
+
1519
+ def _container_logs(runtime: ContainerRuntime, name: str) -> str:
1520
+ """Return recent container logs, or empty string on failure."""
1521
+ result = subprocess.run(
1522
+ [runtime.cmd, "logs", "--tail", "50", name],
1523
+ capture_output=True, text=True,
1524
+ )
1525
+ return (result.stdout + result.stderr).strip() if result.returncode == 0 else ""
1526
+
1527
+
1528
+ def _sync_binary_symlink(shell_path, install, mounts, log) -> None:
1529
+ """Update a stale binary symlink in the shell dir to match the detected version.
1530
+
1531
+ When ``binary_mounts()`` returns both an install-dir mount and a binary
1532
+ mount, podman follows the destination symlink, landing the binary mount
1533
+ inside the install-dir subtree where the directory mount shadows it.
1534
+ Keeping the symlink current ensures the install-dir mount serves the
1535
+ correct binary version.
1536
+ """
1537
+ link = shell_path / ".local" / "bin" / install.name
1538
+ if not link.is_symlink():
1539
+ return
1540
+ # Find the install-dir mount destination (e.g. /home/agent/.local/share/claude).
1541
+ install_dir_dest = None
1542
+ for m in mounts:
1543
+ if m.source == install.install_dir:
1544
+ install_dir_dest = m.destination
1545
+ break
1546
+ if not install_dir_dest:
1547
+ return # No install-dir mount; no shadowing risk.
1548
+ try:
1549
+ relative = install.binary.relative_to(install.install_dir)
1550
+ except ValueError:
1551
+ return
1552
+ expected = str(Path(install_dir_dest) / relative)
1553
+ current = os.readlink(str(link))
1554
+ if current == expected:
1555
+ return
1556
+ link.unlink()
1557
+ link.symlink_to(expected)
1558
+ log.info("Updated %s symlink: %s → %s", install.name, current, expected)
1559
+
1560
+
1561
+ def _validate_mounts(mounts: list, logger) -> None:
1562
+ """Warn about mount sources that don't exist on the host.
1563
+
1564
+ Called before ``runtime.run()`` to catch issues early with a clear
1565
+ message instead of a cryptic Podman error.
1566
+ """
1567
+ for mount in mounts:
1568
+ src = mount.source
1569
+ if not src.exists():
1570
+ logger.warning("Mount source missing: %s → %s", src, mount.destination)
1571
+ print(
1572
+ f"Warning: mount source does not exist: {src}",
1573
+ file=sys.stderr,
1574
+ )
1575
+
1576
+
1577
+ _ROTATE_MAX_BYTES = 1_048_576 # 1 MiB
1578
+
1579
+
1580
+ def _rotate_file(path: Path) -> None:
1581
+ """Rotate *path* if it exceeds the size threshold."""
1582
+ try:
1583
+ size = path.stat().st_size
1584
+ if not isinstance(size, int) or size < _ROTATE_MAX_BYTES:
1585
+ return
1586
+ except (OSError, TypeError):
1587
+ return
1588
+ backup = path.with_suffix(path.suffix + ".1")
1589
+ path.rename(backup)
1590
+ path.touch()
1591
+
1592
+
1593
+ def validate_socket_path(socket_path: Path) -> None:
1594
+ """Raise ValueError if *socket_path* exceeds the AF_UNIX length limit."""
1595
+ path_len = len(str(socket_path))
1596
+ if path_len >= _UNIX_SOCKET_PATH_LIMIT:
1597
+ raise ValueError(
1598
+ f"Socket path too long ({path_len} >= {_UNIX_SOCKET_PATH_LIMIT}): "
1599
+ f"{socket_path}"
1600
+ )