Skip to content

Runner

runner

Launches AI agents in hardened Podman containers.

Builds the environment, prepares agent config, and launches a hardened Podman container with the requested AI agent. Three launch modes:

  • Headless: fire-and-forget with a prompt (run_headless)
  • Interactive: user logs in, agent is ready (run_interactive)
  • Web: toad served over HTTP (run_web)

All user config is runtime (env vars + volumes) — no L2 image build needed. Gate is on by default (safe-by-default egress control).

AgentRunner(*, sandbox=None, runtime=None, roster=None, base_image='fedora:44', family=None, cfg=None)

Composes sandbox + agent config into a single container launch.

All three run methods follow the same flow:

  1. Ensure L0+L1 images exist (build if missing)
  2. Prepare agent-config directory (wrapper, instructions, prompt)
  3. Assemble environment variables and volume mounts
  4. Optionally set up gate (mirror repo, create token)
  5. Launch container via podman
Source code in src/terok_executor/container/runner.py
def __init__(
    self,
    *,
    sandbox: Sandbox | None = None,
    runtime: ContainerRuntime | None = None,
    roster: AgentRoster | None = None,
    base_image: str = "fedora:44",
    family: str | None = None,
    cfg: SandboxConfig | None = None,
) -> None:
    if sandbox is not None and runtime is not None and sandbox.runtime is not runtime:
        # Split backends would mean port reservations on one runtime
        # get used by containers launched via a different runtime —
        # a subtle class of bug (``run_web`` vs ``sandbox.run``) that
        # is easier to rule out at construction time.
        raise ValueError(
            "AgentRunner: sandbox.runtime and runtime must be the same backend "
            "instance; pass only one or ensure sandbox was constructed with runtime"
        )
    self._base_image = base_image
    self._family = family
    self._sandbox: Sandbox | None = sandbox
    self._runtime: ContainerRuntime | None = runtime
    self._roster: AgentRoster | None = roster
    self._cfg: SandboxConfig | None = cfg

sandbox property

Lazy-init sandbox facade.

When an explicit runtime was supplied but no sandbox, the sandbox is constructed with that same runtime so the two share one backend instance.

runtime property

Return the container runtime used for observation and lifecycle.

Falls back to the sandbox's runtime when the caller did not supply one — keeps the two in sync by construction.

roster property

Lazy-init agent roster.

run_headless(provider, repo, *, prompt, branch=None, model=None, max_turns=None, timeout=1800, gate=True, name=None, follow=False, unrestricted=True, gpu=False, memory=None, cpus=None, hooks=None, human_name=None, human_email=None, authorship=None, shared_dir=None, shared_mount='/shared', timezone=None, project_id='', task_id='', dossier_path=None)

Launch a headless agent run. Returns container name.

The agent executes the prompt against repo (local path or git URL) and exits when done or when timeout is reached. Set follow=True to block until the agent finishes (the CLI does this by default).

project_id, task_id, dossier_path propagate the terok orchestrator's identity into the per-container supervisor sidecar. Defaults preserve the standalone-executor case (no terok above).

Source code in src/terok_executor/container/runner.py
def run_headless(
    self,
    provider: str,
    repo: str,
    *,
    prompt: str,
    branch: str | None = None,
    model: str | None = None,
    max_turns: int | None = None,
    timeout: int = 1800,
    gate: bool = True,
    name: str | None = None,
    follow: bool = False,
    unrestricted: bool = True,
    gpu: bool = False,
    memory: str | None = None,
    cpus: str | None = None,
    hooks: LifecycleHooks | None = None,
    human_name: str | None = None,
    human_email: str | None = None,
    authorship: str | None = None,
    shared_dir: Path | None = None,
    shared_mount: str = "/shared",
    timezone: str | None = None,
    project_id: str = "",
    task_id: str = "",
    dossier_path: Path | str | None = None,
) -> str:
    """Launch a headless agent run. Returns container name.

    The agent executes the *prompt* against *repo* (local path or git URL)
    and exits when done or when *timeout* is reached.  Set *follow=True*
    to block until the agent finishes (the CLI does this by default).

    *project_id*, *task_id*, *dossier_path* propagate the terok
    orchestrator's identity into the per-container supervisor sidecar.
    Defaults preserve the standalone-executor case (no terok above).
    """
    return self._run(
        provider=provider,
        repo=repo,
        prompt=prompt,
        branch=branch,
        model=model,
        max_turns=max_turns,
        timeout=timeout,
        gate=gate,
        name=name,
        follow=follow,
        mode="headless",
        unrestricted=unrestricted,
        gpu=gpu,
        memory=memory,
        cpus=cpus,
        hooks=hooks,
        human_name=human_name,
        human_email=human_email,
        authorship=authorship,
        shared_dir=shared_dir,
        shared_mount=shared_mount,
        timezone=timezone,
        project_id=project_id,
        supervisor_task_id=task_id,
        dossier_path=dossier_path,
    )

run_interactive(provider, repo, *, branch=None, gate=True, name=None, unrestricted=True, gpu=False, memory=None, cpus=None, hooks=None, human_name=None, human_email=None, authorship=None, shared_dir=None, shared_mount='/shared', timezone=None, project_id='', task_id='', dossier_path=None)

Launch an interactive container. Returns container name.

The container stays up after init; user logs in via podman exec.

See run_headless for the project_id / task_id / dossier_path semantics.

Source code in src/terok_executor/container/runner.py
def run_interactive(
    self,
    provider: str,
    repo: str,
    *,
    branch: str | None = None,
    gate: bool = True,
    name: str | None = None,
    unrestricted: bool = True,
    gpu: bool = False,
    memory: str | None = None,
    cpus: str | None = None,
    hooks: LifecycleHooks | None = None,
    human_name: str | None = None,
    human_email: str | None = None,
    authorship: str | None = None,
    shared_dir: Path | None = None,
    shared_mount: str = "/shared",
    timezone: str | None = None,
    project_id: str = "",
    task_id: str = "",
    dossier_path: Path | str | None = None,
) -> str:
    """Launch an interactive container. Returns container name.

    The container stays up after init; user logs in via ``podman exec``.

    See [`run_headless`][terok_executor.container.runner.AgentRunner.run_headless]
    for the *project_id* / *task_id* / *dossier_path* semantics.
    """
    return self._run(
        provider=provider,
        repo=repo,
        branch=branch,
        gate=gate,
        name=name,
        mode="interactive",
        unrestricted=unrestricted,
        gpu=gpu,
        memory=memory,
        cpus=cpus,
        hooks=hooks,
        human_name=human_name,
        human_email=human_email,
        authorship=authorship,
        shared_dir=shared_dir,
        shared_mount=shared_mount,
        timezone=timezone,
        project_id=project_id,
        supervisor_task_id=task_id,
        dossier_path=dossier_path,
    )

run_web(repo, *, port=None, branch=None, gate=True, name=None, public_url=None, unrestricted=True, gpu=False, memory=None, cpus=None, hooks=None, human_name=None, human_email=None, authorship=None, shared_dir=None, shared_mount='/shared', timezone=None, project_id='', task_id='', dossier_path=None)

Launch a toad web container. Returns container name.

If port is None, an available port is auto-allocated.

See run_headless for the project_id / task_id / dossier_path semantics.

Source code in src/terok_executor/container/runner.py
def run_web(
    self,
    repo: str,
    *,
    port: int | None = None,
    branch: str | None = None,
    gate: bool = True,
    name: str | None = None,
    public_url: str | None = None,
    unrestricted: bool = True,
    gpu: bool = False,
    memory: str | None = None,
    cpus: str | None = None,
    hooks: LifecycleHooks | None = None,
    human_name: str | None = None,
    human_email: str | None = None,
    authorship: str | None = None,
    shared_dir: Path | None = None,
    shared_mount: str = "/shared",
    timezone: str | None = None,
    project_id: str = "",
    task_id: str = "",
    dossier_path: Path | str | None = None,
) -> str:
    """Launch a toad web container. Returns container name.

    If *port* is None, an available port is auto-allocated.

    See [`run_headless`][terok_executor.container.runner.AgentRunner.run_headless]
    for the *project_id* / *task_id* / *dossier_path* semantics.
    """
    if port is None:
        with self.runtime.reserve_port() as reservation:
            port = reservation.port
    return self._run(
        provider="claude",  # toad uses claude as default
        repo=repo,
        branch=branch,
        gate=gate,
        name=name,
        mode="web",
        port=port,
        public_url=public_url,
        unrestricted=unrestricted,
        gpu=gpu,
        memory=memory,
        cpus=cpus,
        hooks=hooks,
        human_name=human_name,
        human_email=human_email,
        authorship=authorship,
        shared_dir=shared_dir,
        shared_mount=shared_mount,
        timezone=timezone,
        project_id=project_id,
        supervisor_task_id=task_id,
        dossier_path=dossier_path,
    )

run_tool(tool, repo, *, tool_args=(), branch=None, gate=True, name=None, follow=True, timeout=600, timezone=None, project_id='', task_id='', dossier_path=None)

Launch a sidecar tool container. Returns container name.

Runs the named tool in a lightweight sidecar L1 image (no agent CLIs). The tool receives the real API key from the credential store — not a phantom token.

See run_headless for the project_id / task_id / dossier_path semantics.

Source code in src/terok_executor/container/runner.py
def run_tool(
    self,
    tool: str,
    repo: str,
    *,
    tool_args: tuple[str, ...] = (),
    branch: str | None = None,
    gate: bool = True,
    name: str | None = None,
    follow: bool = True,
    timeout: int = 600,
    timezone: str | None = None,
    project_id: str = "",
    task_id: str = "",
    dossier_path: Path | str | None = None,
) -> str:
    """Launch a sidecar tool container. Returns container name.

    Runs the named tool in a lightweight sidecar L1 image (no agent
    CLIs).  The tool receives the real API key from the credential
    store — not a phantom token.

    See [`run_headless`][terok_executor.container.runner.AgentRunner.run_headless]
    for the *project_id* / *task_id* / *dossier_path* semantics.
    """
    return self._run(
        provider=tool,
        repo=repo,
        mode="tool",
        gate=gate,
        name=name,
        follow=follow,
        timeout=timeout,
        tool_args=tool_args,
        branch=branch,
        timezone=timezone,
        project_id=project_id,
        supervisor_task_id=task_id,
        dossier_path=dossier_path,
    )

launch_prepared(*, env, volumes, image, command, name, task_dir, gpu=False, memory=None, cpus=None, unrestricted=True, sealed=False, hooks=None, extra_args=None, hostname=None, annotations=None, runtime=None, project_id='', task_id='', dossier_path=None, per_container=None)

Launch a container from a caller-prepared env, volumes, image, and command.

Use this when the caller has already assembled the environment and volume specs — e.g. the terok orchestrator, which computes project-specific env via build_task_env_and_volumes and owns the container naming policy. For end-to-end runs from a repo and prompt (CLI-style), use run_headless, run_interactive, or run_web instead.

In sealed isolation mode (sealed=True), the sandbox splits the launch into createcopy_tostart instead of a single run — no host↔container bind mounts remain after startup.

Parameters:

Name Type Description Default
env dict[str, str]

Environment variables injected into the container.

required
volumes list[VolumeSpec]

Host↔container directory specs (sandbox decides mount vs inject).

required
image str

Image tag to run.

required
command list[str]

Command + args to execute as PID 1.

required
name str

Container name (must be unique on the host).

required
task_dir Path

Per-task directory used for per-container shield state.

required
gpu bool

Pass GPU device args when True.

False
memory str | None

Podman --memory value ("4g" etc.); None = unlimited.

None
cpus str | None

Podman --cpus value ("2.0" etc.); None = unlimited.

None
unrestricted bool

When False, adds --security-opt no-new-privileges.

True
sealed bool

Enable sealed isolation (no bind mounts).

False
hooks LifecycleHooks | None

Optional lifecycle callbacks fired around the launch.

None
extra_args list[str] | None

Additional raw podman run flags (e.g. port publishing).

None
hostname str | None

Override the in-container hostname (podman --hostname). When None (default), podman assigns the short container ID.

None
annotations Mapping[str, str] | None

OCI annotations forwarded as --annotation k=v; validated against SAFE_ANNOTATION_KEYS. Typed channel for orchestrator metadata the shield reads, distinct from the freeform extra_args.

None
runtime str | None

OCI runtime selector forwarded to RunSpec.runtime. None (default) leaves the choice to podman; "krun" selects the libkrun microVM backend and also drives shield's dnsmasq bind selection. Prefer this over passing --runtime via extra_args — sandbox emits the flag itself and shield reads the value to pick the right firewall topology.

None
project_id str

Identity written into the per-container supervisor sidecar so the supervisor can scope its state to the calling terok project. Default "" preserves the standalone-executor case where no terok orchestrator sits above the runner.

''
task_id str

Per-task identity written into the supervisor sidecar alongside project_id. Default "" for the standalone case.

''
dossier_path Path | str | None

Path to the per-task dossier file the shield reads at container start. Default None omits the field from the sidecar — only orchestrated runs carry a dossier.

None
per_container PerContainerResources | None

Pre-allocated per-container socket dir / TCP ports. When provided, the launch uses these instead of allocating its own — so a caller that already threaded the same instance through env assembly (assemble_container_env) keeps the vault-routing env vars and the supervisor binding on identical ports. Default None allocates internally (the standalone path, and external callers that assemble env without per-container routing).

None

Returns:

Type Description
str

The container name (same as name).

Raises:

Type Description
BuildError

When GPU was requested but the host has no functioning NVIDIA CDI.

Source code in src/terok_executor/container/runner.py
def launch_prepared(
    self,
    *,
    env: dict[str, str],
    volumes: list[VolumeSpec],
    image: str,
    command: list[str],
    name: str,
    task_dir: Path,
    gpu: bool = False,
    memory: str | None = None,
    cpus: str | None = None,
    unrestricted: bool = True,
    sealed: bool = False,
    hooks: LifecycleHooks | None = None,
    extra_args: list[str] | None = None,
    hostname: str | None = None,
    annotations: Mapping[str, str] | None = None,
    runtime: str | None = None,
    project_id: str = "",
    task_id: str = "",
    dossier_path: Path | str | None = None,
    per_container: PerContainerResources | None = None,
) -> str:
    """Launch a container from a caller-prepared env, volumes, image, and command.

    Use this when the caller has already assembled the environment and
    volume specs — e.g. the terok orchestrator, which computes
    project-specific env via ``build_task_env_and_volumes`` and owns
    the container naming policy.  For end-to-end runs from a repo and
    prompt (CLI-style), use [`run_headless`][terok_executor.container.runner.AgentRunner.run_headless], [`run_interactive`][terok_executor.container.runner.AgentRunner.run_interactive],
    or [`run_web`][terok_executor.container.runner.AgentRunner.run_web] instead.

    In sealed isolation mode (*sealed=True*), the sandbox splits the
    launch into ``create`` → ``copy_to`` → ``start`` instead of a
    single ``run`` — no host↔container bind mounts remain after startup.

    Args:
        env: Environment variables injected into the container.
        volumes: Host↔container directory specs (sandbox decides mount vs inject).
        image: Image tag to run.
        command: Command + args to execute as PID 1.
        name: Container name (must be unique on the host).
        task_dir: Per-task directory used for per-container shield state.
        gpu: Pass GPU device args when True.
        memory: Podman ``--memory`` value (``"4g"`` etc.); ``None`` = unlimited.
        cpus: Podman ``--cpus`` value (``"2.0"`` etc.); ``None`` = unlimited.
        unrestricted: When False, adds ``--security-opt no-new-privileges``.
        sealed: Enable sealed isolation (no bind mounts).
        hooks: Optional lifecycle callbacks fired around the launch.
        extra_args: Additional raw ``podman run`` flags (e.g. port publishing).
        hostname: Override the in-container hostname (podman ``--hostname``).
            When ``None`` (default), podman assigns the short container ID.
        annotations: OCI annotations forwarded as ``--annotation k=v``;
            validated against
            [`SAFE_ANNOTATION_KEYS`][terok_sandbox.sandbox.SAFE_ANNOTATION_KEYS].
            Typed channel for orchestrator metadata the shield reads,
            distinct from the freeform *extra_args*.
        runtime: OCI runtime selector forwarded to
            [`RunSpec.runtime`][terok_sandbox.sandbox.RunSpec.runtime].
            ``None`` (default) leaves the choice to podman; ``"krun"``
            selects the libkrun microVM backend and also drives
            shield's dnsmasq bind selection.  Prefer this over
            passing ``--runtime`` via *extra_args* — sandbox emits
            the flag itself and shield reads the value to pick the
            right firewall topology.
        project_id: Identity written into the per-container
            supervisor sidecar so the supervisor can scope its
            state to the calling terok project.  Default ``""``
            preserves the standalone-executor case where no terok
            orchestrator sits above the runner.
        task_id: Per-task identity written into the supervisor
            sidecar alongside *project_id*.  Default ``""`` for
            the standalone case.
        dossier_path: Path to the per-task dossier file the
            shield reads at container start.  Default ``None``
            omits the field from the sidecar — only orchestrated
            runs carry a dossier.
        per_container: Pre-allocated per-container socket dir / TCP
            ports.  When provided, the launch uses these instead of
            allocating its own — so a caller that already threaded
            the same instance through env assembly
            ([`assemble_container_env`][terok_executor.container.env.assemble_container_env])
            keeps the vault-routing env vars and the supervisor
            binding on identical ports.  Default ``None`` allocates
            internally (the standalone path, and external callers
            that assemble env without per-container routing).

    Returns:
        The container name (same as *name*).

    Raises:
        BuildError: When GPU was requested but the host has no functioning
            NVIDIA CDI.
    """
    from terok_executor.integrations.sandbox import (
        GpuConfigError,
        RunSpec,
        Sharing,
        VolumeSpec,
        allocate_per_container_resources,
    )

    from .sidecar import write_supervisor_sidecar

    cfg = self.sandbox.config

    # Per-container socket dir / TCP ports.  Allocated here so the
    # mount, the env vars the in-container bridge reads, and the
    # sidecar JSON the supervisor reads all see the same values —
    # the only path that keeps concurrent containers from colliding
    # on the singletons baked into ``cfg``.  When the caller already
    # allocated one (``_run`` threads it through env assembly too),
    # reuse that instance so the vault-routing env vars and this
    # binding land on identical ports — a second allocation here
    # would hand back different ports and re-introduce the TCP-mode
    # cross-container collision.
    if per_container is None:
        per_container = allocate_per_container_resources(cfg, name)

    # Bind-mount the per-container socket dir at /run/terok/.  The
    # supervisor's later-bound vault.sock + ssh-agent.sock surface
    # inside the container via this single mount (instead of two
    # singleton file-mounts that two containers would collide on).
    env = dict(env)
    volumes = list(volumes)
    volumes.append(
        VolumeSpec(
            per_container.container_runtime_dir,
            "/run/terok",
            sharing=Sharing.SHARED,
            live=True,
        )
    )
    # TCP-mode env vars carry the per-container port, not the
    # host-singleton ``cfg.token_broker_port`` — the launch flow
    # routes through the per-container ports only.
    if cfg.services_mode == "tcp":
        if per_container.token_broker_port is not None:
            env["TEROK_TOKEN_BROKER_PORT"] = str(per_container.token_broker_port)
        if per_container.ssh_signer_port is not None:
            env["TEROK_SSH_SIGNER_PORT"] = str(per_container.ssh_signer_port)
        if per_container.gate_port is not None:
            env["TEROK_GATE_PORT"] = str(per_container.gate_port)

    # The gate is wired when the prepared env carries a gate token
    # (set by ``_setup_gate`` / the orchestrator).  When active, the
    # supervisor needs the mirror base path, the token, and — in TCP
    # mode — the port to serve the gate in-process.
    gate_active = "TEROK_GATE_TOKEN" in env

    # Write the per-container supervisor sidecar before podman run.
    # The terok-sandbox OCI hook installed by ``terok-sandbox setup``
    # reads this file on container start and spawns one supervisor
    # per container; without it the supervisor refuses to start.
    sidecar_path = write_supervisor_sidecar(
        name,
        cfg=cfg,
        per_container=per_container,
        project_id=project_id,
        task_id=task_id,
        dossier_path=dossier_path,
        gate_base_path=str(cfg.gate_base_path) if gate_active else None,
        gate_token=env["TEROK_GATE_TOKEN"] if gate_active else None,
        gate_port=per_container.gate_port if gate_active else None,
    )
    # Fail closed: a missing sidecar means the supervisor OCI hook
    # never fires, so the container would launch with no vault,
    # clearance, or signer behind it. Refuse the launch rather than
    # run unsupervised.
    if sidecar_path is None:
        raise BuildError(
            f"supervisor sidecar write failed for {name}; refusing to launch "
            "an unsupervised container (no vault/clearance/signer)"
        )
    # The supervisor OCI hook fires only when this annotation is
    # present (matched by ``when.annotations`` in the hook
    # descriptor) and reads its value as the sidecar location —
    # no XDG guessing, one anchor.
    spec_annotations = dict(annotations or {})
    spec_annotations["terok.sandbox.sidecar"] = str(sidecar_path)

    loopback_ports = tuple(
        p
        for p in (
            per_container.gate_port,
            per_container.token_broker_port,
            per_container.ssh_signer_port,
        )
        if p is not None
    )

    spec = RunSpec(
        container_name=name,
        image=image,
        env=env,
        volumes=tuple(volumes),
        command=tuple(command),
        task_dir=task_dir,
        gpu_enabled=gpu,
        memory=memory,
        cpus=cpus,
        extra_args=tuple(extra_args or ()),
        unrestricted=unrestricted,
        sealed=sealed,
        hostname=hostname,
        annotations=spec_annotations,
        runtime=runtime,
        loopback_ports=loopback_ports,
    )

    try:
        self.sandbox.run(spec, hooks=hooks)
    except GpuConfigError as exc:
        raise BuildError(str(exc)) from exc

    return name

wait_for_exit(container_name, timeout=None)

Block until container_name exits; return its exit code.

Raises TimeoutError when timeout elapses before the container exits — signalled out of band so a container that legitimately exits with code 124 (the timeout(1) convention) is returned unambiguously as its real exit code, not conflated with the wait timing out.

Raises RuntimeError when podman wait itself fails (non-zero returncode, e.g. unknown container) or returns output that is not a container exit code — the podman error is never impersonated as the container's exit code, which would let a "no such container" diagnostic leak out as exit code 125.

Raises FileNotFoundError when podman is not on PATH. Intentionally re-implements the wait loop instead of delegating to Sandbox.wait_for_exit, which swallows subprocess.TimeoutExpired and returns the 124 sentinel — fine for fire-and-forget generic waits, lossy for task-level callers that need to record the real exit code.

Source code in src/terok_executor/container/runner.py
def wait_for_exit(
    self,
    container_name: str,
    timeout: float | None = None,
) -> int:
    """Block until *container_name* exits; return its exit code.

    Raises [`TimeoutError`][TimeoutError] when *timeout* elapses before the
    container exits — signalled out of band so a container that
    legitimately exits with code 124 (the ``timeout(1)`` convention)
    is returned unambiguously as its real exit code, not conflated
    with the wait timing out.

    Raises [`RuntimeError`][RuntimeError] when ``podman wait`` itself fails
    (non-zero returncode, e.g. unknown container) or returns output
    that is not a container exit code — the podman error is never
    impersonated as the container's exit code, which would let a
    "no such container" diagnostic leak out as exit code 125.

    Raises [`FileNotFoundError`][FileNotFoundError] when ``podman`` is not on PATH.
    Intentionally re-implements the wait loop instead of delegating
    to `Sandbox.wait_for_exit`, which swallows
    [`subprocess.TimeoutExpired`][subprocess.TimeoutExpired] and returns the 124 sentinel
    — fine for fire-and-forget generic waits, lossy for task-level
    callers that need to record the real exit code.
    """
    import subprocess

    try:
        proc = subprocess.run(
            ["podman", "wait", container_name],
            check=False,
            capture_output=True,
            text=True,
            timeout=timeout,
        )
    except subprocess.TimeoutExpired as exc:
        raise TimeoutError(
            f"container {container_name!r} did not exit within {timeout}s"
        ) from exc

    if proc.returncode != 0:
        detail = (proc.stderr or proc.stdout or "").strip() or "<no output>"
        raise RuntimeError(
            f"podman wait {container_name!r} failed (returncode={proc.returncode}): {detail}"
        )

    stdout = (proc.stdout or "").strip()
    try:
        return int(stdout)
    except ValueError as exc:
        raise RuntimeError(
            f"podman wait {container_name!r} returned unexpected output: "
            f"stdout={proc.stdout!r}, stderr={proc.stderr!r}"
        ) from exc

logs(container_name, *, tail=None, timestamps=False, since=None)

Return the container's logged output as a single string.

One-shot retrieval for the "just show me what ran" case. For live streaming (human watching), use stream_logs_process; for archival, use capture_logs.

Raises RuntimeError when podman logs returns a non-zero status (e.g. unknown container) — the diagnostic is surfaced rather than impersonated as empty output. FileNotFoundError propagates when podman is not on PATH.

Source code in src/terok_executor/container/runner.py
def logs(
    self,
    container_name: str,
    *,
    tail: int | None = None,
    timestamps: bool = False,
    since: str | None = None,
) -> str:
    """Return the container's logged output as a single string.

    One-shot retrieval for the "just show me what ran" case.  For live
    streaming (human watching), use [`stream_logs_process`][terok_executor.container.runner.AgentRunner.stream_logs_process]; for
    archival, use [`capture_logs`][terok_executor.container.runner.AgentRunner.capture_logs].

    Raises [`RuntimeError`][RuntimeError] when ``podman logs`` returns a non-zero
    status (e.g. unknown container) — the diagnostic is surfaced rather
    than impersonated as empty output.  [`FileNotFoundError`][FileNotFoundError]
    propagates when ``podman`` is not on PATH.
    """
    import subprocess

    cmd = _build_logs_cmd(container_name, tail=tail, timestamps=timestamps, since=since)
    proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
    if proc.returncode != 0:
        detail = (proc.stderr or proc.stdout or "").strip() or "<no output>"
        raise RuntimeError(
            f"podman logs {container_name!r} failed (returncode={proc.returncode}): {detail}"
        )
    return (proc.stdout or "") + (proc.stderr or "")

capture_logs(container_name, dest, *, timestamps=True, timeout=60.0)

Capture a container's logs to dest; return True on success.

Streams stdout directly to dest (bytes) so large logs do not need to fit in memory. Used at task-archive time to freeze the container's output onto the host filesystem before removal.

On any failure — missing podman, podman error, timeout — dest is removed and False is returned so the caller sees one signal, not a partially-written file.

Source code in src/terok_executor/container/runner.py
def capture_logs(
    self,
    container_name: str,
    dest: Path,
    *,
    timestamps: bool = True,
    timeout: float = 60.0,
) -> bool:
    """Capture a container's logs to *dest*; return ``True`` on success.

    Streams stdout directly to *dest* (bytes) so large logs do not need
    to fit in memory.  Used at task-archive time to freeze the
    container's output onto the host filesystem before removal.

    On any failure — missing podman, podman error, timeout — *dest* is
    removed and ``False`` is returned so the caller sees one signal,
    not a partially-written file.
    """
    import subprocess

    cmd = _build_logs_cmd(container_name, timestamps=timestamps)
    try:
        with dest.open("wb") as f:
            proc = subprocess.run(
                cmd,
                stdout=f,
                stderr=subprocess.PIPE,
                timeout=timeout,
                check=False,
            )
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        dest.unlink(missing_ok=True)
        return False

    if proc.returncode != 0:
        dest.unlink(missing_ok=True)
        return False
    return True

stream_logs_process(container_name, *, follow=False, tail=None, timestamps=False, merge_stderr=False)

Spawn a long-running podman logs process; return the Popen.

The raw subprocess handle is exposed deliberately: live-log consumers (TUI log viewer, interactive task logs -f) need fd-level control — select() between reads, SIGINT handling, stop-event polling — that a higher-level iterator abstraction would hide badly. Every current caller's event loop already looks like select([proc.stdout], …) → read1() so returning the Popen matches existing patterns instead of fighting them.

Caller owns the subprocess. Typical pattern::

proc = runner.stream_logs_process(cname, follow=True)
try:
    for chunk in iter(proc.stdout.read1, b""):
        ...
finally:
    proc.terminate()
    proc.wait()

When merge_stderr is True, stderr is folded into stdout (matches subprocess.STDOUT); otherwise stderr is a separate pipe the caller can drain.

FileNotFoundError propagates when podman is not on PATH — callers handle it (usually as a user-facing "podman not installed" error).

Source code in src/terok_executor/container/runner.py
def stream_logs_process(
    self,
    container_name: str,
    *,
    follow: bool = False,
    tail: int | None = None,
    timestamps: bool = False,
    merge_stderr: bool = False,
) -> subprocess.Popen[bytes]:
    """Spawn a long-running ``podman logs`` process; return the ``Popen``.

    The raw subprocess handle is exposed deliberately: live-log
    consumers (TUI log viewer, interactive ``task logs -f``) need
    fd-level control — ``select()`` between reads, SIGINT handling,
    stop-event polling — that a higher-level iterator abstraction
    would hide badly.  Every current caller's event loop already looks
    like ``select([proc.stdout], …) → read1()`` so returning the
    ``Popen`` matches existing patterns instead of fighting them.

    Caller owns the subprocess.  Typical pattern::

        proc = runner.stream_logs_process(cname, follow=True)
        try:
            for chunk in iter(proc.stdout.read1, b""):
                ...
        finally:
            proc.terminate()
            proc.wait()

    When *merge_stderr* is True, stderr is folded into stdout
    (matches ``subprocess.STDOUT``); otherwise stderr is a separate
    pipe the caller can drain.

    [`FileNotFoundError`][FileNotFoundError] propagates when ``podman`` is not on
    PATH — callers handle it (usually as a user-facing "podman not
    installed" error).
    """
    import subprocess

    cmd = _build_logs_cmd(container_name, follow=follow, tail=tail, timestamps=timestamps)
    return subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT if merge_stderr else subprocess.PIPE,
    )