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.
- kanibako/__init__.py +3 -0
- kanibako/__main__.py +6 -0
- kanibako/auth_browser.py +296 -0
- kanibako/auth_parser.py +51 -0
- kanibako/browser_sidecar.py +183 -0
- kanibako/browser_state.py +103 -0
- kanibako/bun_sea.py +144 -0
- kanibako/cli.py +344 -0
- kanibako/commands/__init__.py +0 -0
- kanibako/commands/archive.py +228 -0
- kanibako/commands/box/__init__.py +22 -0
- kanibako/commands/box/_duplicate.py +395 -0
- kanibako/commands/box/_migrate.py +574 -0
- kanibako/commands/box/_parser.py +1178 -0
- kanibako/commands/clean.py +166 -0
- kanibako/commands/crab_cmd.py +480 -0
- kanibako/commands/diagnose.py +239 -0
- kanibako/commands/fork_cmd.py +51 -0
- kanibako/commands/helper_cmd.py +669 -0
- kanibako/commands/image.py +1300 -0
- kanibako/commands/install.py +152 -0
- kanibako/commands/refresh_credentials.py +67 -0
- kanibako/commands/restore.py +298 -0
- kanibako/commands/setup_cmd.py +89 -0
- kanibako/commands/start.py +1600 -0
- kanibako/commands/stop.py +116 -0
- kanibako/commands/system_cmd.py +224 -0
- kanibako/commands/upgrade.py +161 -0
- kanibako/commands/vault_cmd.py +199 -0
- kanibako/commands/workset_cmd.py +552 -0
- kanibako/config.py +514 -0
- kanibako/config_interface.py +573 -0
- kanibako/config_io.py +36 -0
- kanibako/container.py +607 -0
- kanibako/containerfiles.py +58 -0
- kanibako/containers/Containerfile.kanibako +99 -0
- kanibako/containers/Containerfile.template-android +55 -0
- kanibako/containers/Containerfile.template-dotnet +29 -0
- kanibako/containers/Containerfile.template-js +43 -0
- kanibako/containers/Containerfile.template-jvm +27 -0
- kanibako/containers/Containerfile.template-systems +46 -0
- kanibako/containers/__init__.py +0 -0
- kanibako/crabs.py +89 -0
- kanibako/errors.py +33 -0
- kanibako/freshness.py +67 -0
- kanibako/git.py +114 -0
- kanibako/helper_client.py +132 -0
- kanibako/helper_listener.py +538 -0
- kanibako/helpers.py +339 -0
- kanibako/hygiene.py +296 -0
- kanibako/image_sharing.py +133 -0
- kanibako/instructions.py +160 -0
- kanibako/log.py +31 -0
- kanibako/names.py +248 -0
- kanibako/paths.py +1483 -0
- kanibako/plugins/__init__.py +10 -0
- kanibako/registry.py +71 -0
- kanibako/rig_bundle.py +121 -0
- kanibako/rig_meta.py +92 -0
- kanibako/rig_registry.py +132 -0
- kanibako/rig_resolve.py +182 -0
- kanibako/rig_source.py +245 -0
- kanibako/scripts/__init__.py +0 -0
- kanibako/scripts/helper-init.sh +45 -0
- kanibako/scripts/kanibako-entry +12 -0
- kanibako/settings_resolve.py +312 -0
- kanibako/settings_seeds.py +154 -0
- kanibako/settings_shares.py +154 -0
- kanibako/shellenv.py +75 -0
- kanibako/snapshots.py +281 -0
- kanibako/targets/__init__.py +173 -0
- kanibako/targets/base.py +243 -0
- kanibako/targets/no_agent.py +58 -0
- kanibako/templates.py +60 -0
- kanibako/templates_image.py +224 -0
- kanibako/tweakcc.py +140 -0
- kanibako/tweakcc_cache.py +171 -0
- kanibako/utils.py +136 -0
- kanibako/workset.py +347 -0
- kanibako_cli-1.5.0.dev14.dist-info/METADATA +15 -0
- kanibako_cli-1.5.0.dev14.dist-info/RECORD +85 -0
- kanibako_cli-1.5.0.dev14.dist-info/WHEEL +5 -0
- kanibako_cli-1.5.0.dev14.dist-info/entry_points.txt +5 -0
- kanibako_cli-1.5.0.dev14.dist-info/licenses/LICENSE.md +594 -0
- 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
|
+
)
|