Skip to content

Env

env

Assembles container environment variables and volume mounts for agent launches.

Both terok-executor run (standalone) and terok (project orchestrator) construct identical container environments — shared config mounts, vault tokens, git identity, unrestricted-mode flags. This module provides the canonical assembly function so that logic lives in one place.

Usage::

from terok_executor.container.env import ContainerEnvSpec, assemble_container_env
from terok_executor import AgentRoster

result = assemble_container_env(
    ContainerEnvSpec(task_id="abc", provider_name="claude", workspace_host_path=ws),
    AgentRoster.shared(),
)
# result.env, result.volumes, result.task_dir

CONTAINER_PROTOCOL = 1 module-attribute

Version of the host↔container env/script contract.

Emitted to every container as TEROK_CONTAINER_PROTOCOL. In-container scripts (terok-env.sh and friends) read it to adapt to the version the host is shipping. Bumped on breaking changes to the env-var or script-interface contract between host and container, not on every release. Old containers on protocol N keep running; new containers get protocol N+1 and carry the matching host-side code.

ContainerEnvSpec(task_id, provider_name, workspace_host_path, code_repo=None, clone_from=None, branch=None, git_author_name=None, git_author_email=None, git_committer_name=None, git_committer_email=None, authorship='agent', human_name='Nobody', human_email='nobody@localhost', credential_scope='standalone', credential_set='default', vault_transport='direct', vault_required=False, scan_leaked_creds=False, enabled_vault_patch_providers=None, disabled_vault_patch_providers=None, expose_credential_providers=frozenset(), unrestricted=True, timezone=None, agent_config_dir=None, shared_dir=None, shared_mount='/shared', task_dir=None, envs_dir=None, extra_volumes=()) dataclass

Specification for container environment assembly.

All fields use primitives or Path — no terok-specific types. Callers pre-resolve domain-specific decisions (security class, authorship mode, SSH mount, gate mirror creation) and pass results here.

task_id instance-attribute

Unique task identifier.

provider_name instance-attribute

Agent provider name (e.g. "claude", "codex").

workspace_host_path instance-attribute

Host-side workspace directory — caller pre-creates, mounted as /workspace:Z.

code_repo = None class-attribute instance-attribute

Git URL to clone inside the container (→ CODE_REPO).

clone_from = None class-attribute instance-attribute

Secondary clone source for online-mode gate optimization (→ CLONE_FROM).

branch = None class-attribute instance-attribute

Git branch to check out (→ GIT_BRANCH).

git_author_name = None class-attribute instance-attribute

Resolved from roster provider if None.

git_author_email = None class-attribute instance-attribute

git_committer_name = None class-attribute instance-attribute

git_committer_email = None class-attribute instance-attribute

authorship = 'agent' class-attribute instance-attribute

Authorship mode consumed by in-container wrappers (→ TEROK_GIT_AUTHORSHIP).

human_name = 'Nobody' class-attribute instance-attribute

Human operator name (→ HUMAN_GIT_NAME). terok resolves from project config / git config; standalone uses the default or --git-identity-from-host.

human_email = 'nobody@localhost' class-attribute instance-attribute

Human operator email (→ HUMAN_GIT_EMAIL).

credential_scope = 'standalone' class-attribute instance-attribute

Scope for vault token creation. terok passes project.id.

credential_set = 'default' class-attribute instance-attribute

Vault storage namespace to read credentials from. Pairs with Authenticator.run's credential_set — if the auth flow stored a token under set foo, the runtime must read from set foo too or the container will see empty env. Default "default" matches the shared host-wide bucket every standalone caller uses; terok overrides for per-project credentials.

vault_transport = 'direct' class-attribute instance-attribute

Vault transport mode: "direct" (HTTP base URL) or "socket" (Unix socket path via socket_env).

vault_required = False class-attribute instance-attribute

When True, raise SystemExit if the vault is unreachable. When False (default), soft-fail to empty env.

scan_leaked_creds = False class-attribute instance-attribute

When True, scan shared mounts for real credential files and emit warnings. Standalone mode defaults to off; terok enables this.

enabled_vault_patch_providers = None class-attribute instance-attribute

Provider subset whose shared config patches should be applied.

None means "all providers with patches". An empty set disables vault config patching entirely. terok uses this to gate experimental OAuth routing without affecting standalone executor defaults.

disabled_vault_patch_providers = None class-attribute instance-attribute

Provider subset whose previously managed config patch values should be removed if still owned by terok. None removes nothing.

expose_credential_providers = frozenset() class-attribute instance-attribute

Providers whose credential file should remain writable in-container.

By default every provider with a vault.credential_file gets the file mounted read-only on top of its shared config dir, so an in-container /login cannot taint the host copy (terok-ai/terok#873). Providers in this set keep the writable bind — used by terok's experimental expose_oauth_token mode where the agent intentionally manages its own token.

unrestricted = True class-attribute instance-attribute

Enable auto-approve flags for all agents.

timezone = None class-attribute instance-attribute

IANA timezone name propagated to the container as TZ.

None (the default) means detect the host's timezone via terok_executor._util.detect_host_timezone — the container then follows the host. Pass an explicit string ("UTC", "Europe/Prague") to override, including to pin the container to UTC for reproducible runs. If neither detection nor an override yields a zone, TZ is not set and the image default applies.

agent_config_dir = None class-attribute instance-attribute

Pre-prepared agent config directory (→ /home/dev/.terok:Z).

shared_dir = None class-attribute instance-attribute

Host-side shared directory. Created by the assembly function if set.

shared_mount = '/shared' class-attribute instance-attribute

Container-side mount point for the shared directory.

task_dir = None class-attribute instance-attribute

Host-side task directory. A temp dir is created if None.

envs_dir = None class-attribute instance-attribute

Base directory for shared config mounts. Uses paths.mounts_dir if None.

extra_volumes = () class-attribute instance-attribute

Additional volume specs from the caller (e.g. SSH mounts from terok).

ContainerEnvResult(env, volumes, task_dir) dataclass

Assembled container environment ready for RunSpec construction.

Not a RunSpec — omits launch-time concerns (container name, image, command, GPU, shield bypass). Callers add those and construct RunSpec.

env instance-attribute

Environment variables for the container.

volumes instance-attribute

Typed volume specs — the sandbox decides whether to mount or inject.

task_dir instance-attribute

Host-side task directory. When spec.task_dir was None, this is an auto-created temporary directory — the caller owns cleanup.

assemble_container_env(spec, roster, *, caller_manages_vault=False, per_container=None)

Assemble container environment variables and volume mounts.

This is the single source of truth for container env/volume assembly. Both AgentRunner._run() and terok's build_task_env_and_volumes() delegate here.

Parameters:

Name Type Description Default
spec ContainerEnvSpec

What the caller wants — all host↔container contract fields.

required
roster AgentRoster

Agent roster for shared mounts, vault routes, provider identity.

required
caller_manages_vault bool

When True, skip phantom-token injection here — the caller injects richer vault tokens itself (e.g. terok's per-provider OAuth tiers, socket transport, SSH signer). Shared config patches (api_base rewrites) still run because the vault is in use; only token injection is delegated.

False

Returns:

Type Description
ContainerEnvResult

Assembled env dict, volume tuple, and resolved task_dir.

Source code in src/terok_executor/container/env.py
def assemble_container_env(
    spec: ContainerEnvSpec,
    roster: AgentRoster,
    *,
    caller_manages_vault: bool = False,
    per_container: PerContainerResources | None = None,
) -> ContainerEnvResult:
    """Assemble container environment variables and volume mounts.

    This is the **single source of truth** for container env/volume assembly.
    Both ``AgentRunner._run()`` and terok's ``build_task_env_and_volumes()``
    delegate here.

    Args:
        spec: What the caller wants — all host↔container contract fields.
        roster: Agent roster for shared mounts, vault routes, provider identity.
        caller_manages_vault: When ``True``, skip phantom-token injection
            here — the caller injects richer vault tokens itself (e.g.
            terok's per-provider OAuth tiers, socket transport, SSH signer).
            Shared config patches (``api_base`` rewrites) still run because
            the vault **is** in use; only token injection is
            delegated.

    Returns:
        Assembled env dict, volume tuple, and resolved task_dir.
    """
    from terok_executor.paths import mounts_dir as _mounts_dir

    env: dict[str, str] = {}
    volumes: list[VolumeSpec] = []

    # 1. Base env
    env["TASK_ID"] = spec.task_id
    env["REPO_ROOT"] = "/workspace"
    env["GIT_RESET_MODE"] = "none"
    env["TEROK_CONTAINER_PROTOCOL"] = str(CONTAINER_PROTOCOL)
    env["CLAUDE_CONFIG_DIR"] = "/home/dev/.claude"

    # 1b. Timezone — explicit override wins, otherwise follow the host
    if tz := spec.timezone or detect_host_timezone():
        env["TZ"] = tz

    # 2. OpenCode provider env
    env.update(roster.collect_opencode_provider_env())

    # 3. Git identity
    env.update(_resolve_git_identity(spec, roster))

    # 4. Authorship env (for per-agent wrappers inside container)
    env["TEROK_GIT_AUTHORSHIP"] = spec.authorship
    env["HUMAN_GIT_NAME"] = spec.human_name
    env["HUMAN_GIT_EMAIL"] = spec.human_email

    # 5. Branch
    if spec.branch:
        env["GIT_BRANCH"] = spec.branch

    # 6. Repo URLs
    if spec.code_repo:
        env["CODE_REPO"] = spec.code_repo
    if spec.clone_from:
        env["CLONE_FROM"] = spec.clone_from

    # 7. Workspace volume
    volumes.append(VolumeSpec(spec.workspace_host_path, "/workspace", sharing=Sharing.PRIVATE))

    # 8. Shared config mounts from roster
    mounts_base = spec.envs_dir or _mounts_dir()
    task_dir = spec.task_dir or Path(tempfile.mkdtemp(prefix=f"terok-executor-{spec.task_id}-"))
    volumes += _shared_config_mounts(
        roster,
        mounts_base,
        expose_credential_providers=spec.expose_credential_providers,
    )

    # 8b. Re-apply vault config patches (idempotent — ensures shared mount
    #     dirs contain correct vault addresses even after state wipe).
    #
    #     NOT gated by caller_manages_vault: that flag only skips
    #     phantom-token injection here because the caller (terok) injects
    #     richer tokens itself — the vault is still in use and
    #     agents still need their config files rewritten to route through
    #     it.  Providers whose credential is exposed directly (Claude OAuth
    #     tier 3) are safe because they have no shared_config_patch.
    from terok_executor.credentials.vault_config import apply_shared_config_patches

    apply_shared_config_patches(
        roster,
        mounts_base,
        providers=spec.enabled_vault_patch_providers,
        disabled_providers=spec.disabled_vault_patch_providers,
    )

    # 9. Vault
    if not caller_manages_vault:
        env.update(
            _inject_vault_tokens(
                roster,
                spec.credential_scope,
                spec.task_id,
                vault_transport=spec.vault_transport,
                vault_required=spec.vault_required,
                credential_set=spec.credential_set,
                per_container=per_container,
            )
        )

    # 9b. Leaked credential scan (runs regardless of caller_manages_vault —
    #     the shared mounts exist either way)
    if spec.scan_leaked_creds:
        from terok_executor.credentials.vault_commands import scan_leaked_credentials

        leaked = scan_leaked_credentials(mounts_base)
        for provider, path in leaked:
            _logger.warning("Real credential in shared mount: %s: %s", provider, path)

    # 10. Agent config mount
    if spec.agent_config_dir:
        volumes.append(
            VolumeSpec(spec.agent_config_dir, "/home/dev/.terok", sharing=Sharing.PRIVATE)
        )

    # 11. Unrestricted mode
    if spec.unrestricted:
        env["TEROK_UNRESTRICTED"] = "1"
        env.update(roster.collect_all_auto_approve_env())

    # 12. Shared task directory
    if spec.shared_dir:
        spec.shared_dir.mkdir(parents=True, exist_ok=True)
        volumes.append(VolumeSpec(spec.shared_dir, spec.shared_mount))
        env["TEROK_SHARED_DIR"] = spec.shared_mount

    # 13. Extra volumes
    volumes.extend(spec.extra_volumes)

    return ContainerEnvResult(env=env, volumes=tuple(volumes), task_dir=task_dir)