tact 0.1.1

Terminal interface for Nanocodex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
#!/usr/bin/env python3
"""Launch the repository in its isolated local development containers."""

from __future__ import annotations

import argparse
import json
import os
import signal
import stat
import subprocess
import sys
import tempfile
import tomllib
import uuid
from collections.abc import Mapping, Sequence
from pathlib import Path

import config


DEVELOPMENT_DIRECTORY = Path(__file__).resolve().parent
TOOLS_FILE = DEVELOPMENT_DIRECTORY / "tools.toml"
DOCKER_TIMEOUT_SECONDS = 120
CLEANUP_TIMEOUT_SECONDS = 30
PERSISTENT_VOLUME = "tact-dev-state"

DOCKER_ENVIRONMENT_NAMES = (
    "DOCKER_CONFIG",
    "DOCKER_CONTEXT",
    "DOCKER_HOST",
    "PATH",
    "XDG_RUNTIME_DIR",
)


def parser() -> argparse.ArgumentParser:
    argument_parser = argparse.ArgumentParser(
        description="Run Tact in the local development container.",
        epilog="Place `--` before arguments intended for Tact.",
    )
    argument_parser.add_argument(
        "--workspace",
        type=Path,
        default=Path.cwd(),
        help="workspace mounted read-write at /workspace (default: current directory)",
    )
    argument_parser.add_argument(
        "--auth",
        choices=("auto", "api-key", "chatgpt"),
        default="auto",
    )
    argument_parser.add_argument(
        "--no-instructions",
        action="store_true",
        help="do not project global AGENTS instructions",
    )
    argument_parser.add_argument(
        "--no-skills", action="store_true", help="do not project local skill roots"
    )
    argument_parser.add_argument(
        "--skill-root",
        action="append",
        default=[],
        type=Path,
        metavar="PATH",
        help="additional skill root to project; may be repeated",
    )
    operation = argument_parser.add_mutually_exclusive_group()
    operation.add_argument(
        "--shell", action="store_true", help="run an interactive Bash shell instead of Tact"
    )
    operation.add_argument(
        "--clean",
        action="store_true",
        help=f"remove the persistent {PERSISTENT_VOLUME} volume and exit",
    )
    argument_parser.add_argument("tact_arguments", nargs=argparse.REMAINDER)
    return argument_parser


def run(
    arguments: argparse.Namespace,
    environment: Mapping[str, str] = os.environ,
) -> int:
    # This validation intentionally precedes every credential read.
    require_local_linux_docker(environment)
    if arguments.clean:
        return _clean_persistent_volume(environment)
    _ensure_persistent_volume(environment)

    workspace = arguments.workspace.expanduser().resolve(strict=True)
    if not workspace.is_dir():
        raise RuntimeError(f"workspace is not a directory: {workspace}")
    if not os.access(workspace, os.W_OK):
        raise RuntimeError(f"workspace is not writable: {workspace}")

    state_directory, state_config_name = prepare_tact_state(environment)

    compose_file = DEVELOPMENT_DIRECTORY / "compose.yaml"
    project_name = f"tact-dev-{uuid.uuid4().hex}"

    with tempfile.TemporaryDirectory(prefix="tact-development-") as temporary:
        temporary_directory = Path(temporary)
        temporary_directory.chmod(0o700)
        private_directory = temporary_directory / "private"
        public_directory = temporary_directory / "public"
        private_directory.mkdir(mode=0o700)
        public_directory.mkdir(mode=0o700)

        iron_proxy_image = _iron_proxy_image()
        generate_proxy_ca(private_directory, environment, iron_proxy_image)
        config.write_public_file(
            public_directory / "ca.crt", (private_directory / "ca.crt").read_bytes()
        )

        credentials = config.select_credentials(arguments.auth, environment)
        with credentials:
            ensure_credentials_not_mounted(
                credentials, (workspace, state_directory)
            )
            config.write_auth_projection(
                credentials, private_directory, public_directory
            )

        config.project_local_agent_files(
            public_directory,
            environment,
            include_instructions=not arguments.no_instructions,
            include_skills=not arguments.no_skills,
            additional_skill_roots=arguments.skill_root,
        )
        tact_arguments = list(arguments.tact_arguments)
        if tact_arguments and tact_arguments[0] == "--":
            tact_arguments.pop(0)
        override_file = _write_compose_override(
            public_directory,
            tact_arguments,
            shell=arguments.shell,
        )
        compose_environment = _compose_environment(
            environment,
            workspace=workspace,
            state_directory=state_directory,
            state_config_name=state_config_name,
            private_directory=private_directory,
            public_directory=public_directory,
            auth_mode=credentials.mode,
            iron_proxy_image=iron_proxy_image,
        )
        compose_arguments = [
            "docker",
            "compose",
            "--file",
            str(compose_file),
            "--file",
            str(override_file),
            "--project-name",
            project_name,
        ]

        active_error: BaseException | None = None
        try:
            return _run_compose(compose_arguments, compose_environment)
        except BaseException as error:
            active_error = error
            raise
        finally:
            try:
                subprocess.run(
                    [*compose_arguments, "down", "--remove-orphans"],
                    env=compose_environment,
                    check=True,
                    timeout=CLEANUP_TIMEOUT_SECONDS,
                )
            except Exception as cleanup_error:
                if active_error is None:
                    raise RuntimeError("failed to clean up development containers") from cleanup_error
                print(
                    f"warning: failed to clean up development containers: {cleanup_error}",
                    file=sys.stderr,
                )


def require_local_linux_docker(
    environment: Mapping[str, str] = os.environ,
) -> None:
    endpoint = environment.get("DOCKER_HOST", "").strip()
    docker_environment = _docker_environment(environment)
    if not endpoint:
        result = _run_docker(
            ["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"],
            docker_environment,
            timeout=10,
        )
        endpoint = result.stdout.strip()
    if not endpoint.startswith(("unix://", "/")):
        raise RuntimeError(
            f"development authentication requires a local Docker socket, got {endpoint!r}"
        )

    result = _run_docker(
        ["info", "--format", "{{.OSType}}"], docker_environment, timeout=15
    )
    if result.stdout.strip() != "linux":
        raise RuntimeError("the development container requires Linux Docker containers")


def prepare_tact_state(
    environment: Mapping[str, str] = os.environ,
) -> tuple[Path, str]:
    selected_config = config.default_tact_config_file(environment)
    if not selected_config.name:
        raise RuntimeError(f"Tact config path has no filename: {selected_config}")

    state_directory = selected_config.parent
    state_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
    state_directory = state_directory.resolve(strict=True)
    if not os.access(state_directory, os.W_OK):
        raise RuntimeError(f"Tact state directory is not writable: {state_directory}")

    mounted_config = state_directory / selected_config.name
    try:
        metadata = mounted_config.lstat()
    except FileNotFoundError:
        descriptor = os.open(
            mounted_config, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600
        )
        os.close(descriptor)
        return state_directory, selected_config.name

    if stat.S_ISLNK(metadata.st_mode):
        raise RuntimeError(f"Tact config must not be a symlink: {selected_config}")
    if not stat.S_ISREG(metadata.st_mode):
        raise RuntimeError(f"Tact config is not a regular file: {selected_config}")
    if not os.access(mounted_config, os.R_OK):
        raise RuntimeError(f"Tact config is not readable: {selected_config}")
    return state_directory, selected_config.name


def ensure_credentials_not_mounted(
    credentials: config.Credentials, writable_roots: Sequence[Path]
) -> None:
    source = credentials.source_path
    if source is None:
        return
    if not any(source.is_relative_to(root) for root in writable_roots):
        return
    raise RuntimeError(
        f"credential file is inside a writable container mount: {source}"
    )


def generate_proxy_ca(
    private_directory: Path,
    environment: Mapping[str, str],
    image: str,
) -> None:
    _run_docker(
        [
            "run",
            "--rm",
            "--network",
            "none",
            "--user",
            f"{os.getuid()}:{os.getgid()}",
            "--volume",
            f"{private_directory}:/out",
            image,
            "generate-ca",
            "-outdir",
            "/out",
            "-alg",
            "ed25519",
            "-name",
            "Tact local development",
        ],
        _docker_environment(environment),
        timeout=DOCKER_TIMEOUT_SECONDS,
    )
    for name in ("ca.crt", "ca.key"):
        path = private_directory / name
        metadata = path.lstat()
        if path.is_symlink() or not stat.S_ISREG(metadata.st_mode):
            raise RuntimeError(f"iron-proxy did not generate a regular {name}")
        path.chmod(0o600)


def _write_compose_override(
    public_directory: Path, tact_arguments: Sequence[str], *, shell: bool = False
) -> Path:
    path = public_directory / "compose.override.json"
    service: dict[str, object] = {"command": list(tact_arguments)}
    if shell:
        if tact_arguments:
            raise RuntimeError("--shell does not accept Tact arguments")
        service = {"command": [], "environment": {"TACT_DEV_SHELL": "1"}}
    document = {"services": {"tact-dev": service}}
    config.write_public_file(
        path, json.dumps(document, separators=(",", ":")).encode()
    )
    return path


def _compose_environment(
    source: Mapping[str, str],
    *,
    workspace: Path,
    state_directory: Path,
    state_config_name: str,
    private_directory: Path,
    public_directory: Path,
    auth_mode: str,
    iron_proxy_image: str,
) -> dict[str, str]:
    result = _docker_environment(source)
    result.update(
        {
            # This is the complete host-to-Compose interface. Real credentials are
            # deliberately absent; Compose receives only paths and safe selectors.
            "TACT_DEV_WORKSPACE": str(workspace),
            "TACT_DEV_STATE_DIR": str(state_directory),
            "TACT_DEV_CONFIG_NAME": state_config_name,
            "TACT_DEV_PRIVATE_DIR": str(private_directory),
            "TACT_DEV_PUBLIC_DIR": str(public_directory),
            "TACT_DEV_UID": str(os.getuid()),
            "TACT_DEV_GID": str(os.getgid()),
            "TACT_DEV_AUTH": auth_mode,
            "TACT_DEV_IRON_IMAGE": iron_proxy_image,
            "TACT_DEV_IMAGE": source.get("TACT_DEV_IMAGE", "tact-dev:local"),
        }
    )
    return result


def _iron_proxy_image() -> str:
    try:
        with TOOLS_FILE.open("rb") as manifest_file:
            manifest = tomllib.load(manifest_file)
        image = manifest["images"]["iron_proxy"]
    except (OSError, KeyError, TypeError, tomllib.TOMLDecodeError) as error:
        raise RuntimeError(f"failed to read iron-proxy image from {TOOLS_FILE}") from error
    if not isinstance(image, str) or "@sha256:" not in image:
        raise RuntimeError("iron-proxy image must be pinned by digest")
    return image


def _docker_environment(source: Mapping[str, str]) -> dict[str, str]:
    return {name: source[name] for name in DOCKER_ENVIRONMENT_NAMES if source.get(name)}


def _clean_persistent_volume(environment: Mapping[str, str]) -> int:
    docker_environment = _docker_environment(environment)
    try:
        result = subprocess.run(
            ["docker", "volume", "rm", PERSISTENT_VOLUME],
            env=docker_environment,
            capture_output=True,
            text=True,
            check=False,
            timeout=CLEANUP_TIMEOUT_SECONDS,
        )
    except (OSError, subprocess.TimeoutExpired) as error:
        raise RuntimeError("failed to remove the development volume") from error
    if result.returncode == 0 or "no such volume" in result.stderr.lower():
        return 0
    detail = (result.stderr or result.stdout or "unknown Docker error").strip()
    raise RuntimeError(f"failed to remove {PERSISTENT_VOLUME}: {detail}")


def _ensure_persistent_volume(environment: Mapping[str, str]) -> None:
    _run_docker(
        ["volume", "create", PERSISTENT_VOLUME],
        _docker_environment(environment),
        timeout=CLEANUP_TIMEOUT_SECONDS,
    )


def _run_docker(
    arguments: Sequence[str],
    environment: Mapping[str, str],
    *,
    timeout: int,
) -> subprocess.CompletedProcess[str]:
    try:
        result = subprocess.run(
            ["docker", *arguments],
            env=environment,
            capture_output=True,
            text=True,
            check=False,
            timeout=timeout,
        )
    except (OSError, subprocess.TimeoutExpired) as error:
        raise RuntimeError("failed to run local Docker") from error
    if result.returncode != 0:
        detail = (result.stderr or result.stdout or "unknown Docker error").strip()
        raise RuntimeError(f"local Docker command failed: {detail}")
    return result


def _run_compose(
    compose_arguments: Sequence[str], environment: Mapping[str, str]
) -> int:
    command = [
        *compose_arguments,
        "run",
        "--rm",
        "tact-dev",
    ]
    try:
        process = subprocess.Popen(command, env=environment)
    except OSError as error:
        raise RuntimeError("failed to start Docker Compose") from error

    previous_handlers: dict[int, signal.Handlers] = {}

    def forward(signum: int, _frame: object) -> None:
        if process.poll() is None:
            process.send_signal(signum)

    for signum in (signal.SIGINT, signal.SIGTERM):
        previous_handlers[signum] = signal.signal(signum, forward)
    try:
        return process.wait()
    finally:
        for signum, handler in previous_handlers.items():
            signal.signal(signum, handler)


def main(argv: Sequence[str] | None = None) -> int:
    try:
        return run(parser().parse_args(argv))
    except (OSError, RuntimeError, ValueError) as error:
        print(f"error: {error}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())