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:
- Ensure L0+L1 images exist (build if missing)
- Prepare agent-config directory (wrapper, instructions, prompt)
- Assemble environment variables and volume mounts
- Optionally set up gate (mirror repo, create token)
- Launch container via podman
Source code in src/terok_executor/container/runner.py
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
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
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
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
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 create → copy_to → start 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 |
None
|
cpus
|
str | None
|
Podman |
None
|
unrestricted
|
bool
|
When False, adds |
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 |
None
|
hostname
|
str | None
|
Override the in-container hostname (podman |
None
|
annotations
|
Mapping[str, str] | None
|
OCI annotations forwarded as |
None
|
runtime
|
str | None
|
OCI runtime selector forwarded to
|
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 |
''
|
task_id
|
str
|
Per-task identity written into the supervisor
sidecar alongside project_id. Default |
''
|
dossier_path
|
Path | str | None
|
Path to the per-task dossier file the
shield reads at container start. Default |
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
( |
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
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 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 | |
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
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
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
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).