Skip to content

Auth

auth

Authenticates AI coding agents via OAuth or API key.

Two public entry points:

  • authenticate(project_id, provider, *, mounts_dir, image) — dispatches based on the provider's modes field: prompts for an API key (no container) or launches an auth container with the vendor CLI.
  • store_api_key(provider, api_key) — stores an API key directly in the credential DB (non-interactive fast path for CI).

AUTH_PROVIDERS is a registry dict populated from the YAML roster at package load time; authenticate looks up the provider by name and delegates to the matching flow.

AUTH_PROVIDERS = {} module-attribute

All known auth providers (agents + tools), keyed by name. Loaded from resources/agents/*.yaml.

AuthProvider(name, label, host_dir_name, container_mount, command, banner_hint, extra_run_args=tuple(), modes=('api_key',), api_key_hint='', post_capture_state=dict()) dataclass

Describes how to authenticate one tool/agent.

name instance-attribute

Short key used in CLI and TUI dispatch (e.g. "codex").

label instance-attribute

Human-readable display name (e.g. "Codex").

host_dir_name instance-attribute

Single-segment directory name under mounts_dir() (e.g. "_codex-config").

container_mount instance-attribute

Mount point inside the container (e.g. "/home/dev/.codex").

command instance-attribute

Command to execute inside the container (OAuth mode only).

banner_hint instance-attribute

Provider-specific help text shown before the container runs.

extra_run_args = field(default_factory=tuple) class-attribute instance-attribute

Additional podman run arguments (e.g. port forwarding).

modes = ('api_key',) class-attribute instance-attribute

Supported auth modes: "oauth" (container), "api_key" (fast path).

api_key_hint = '' class-attribute instance-attribute

Hint shown when prompting for an API key (URL to get one).

post_capture_state = field(default_factory=dict) class-attribute instance-attribute

JSON state files to write after credential capture.

Maps filename → key-value dict to merge into a JSON file in the auth mount directory. Example: {".claude.json": {"hasCompletedOnboarding": true}} marks Claude Code onboarding as complete so the first-run wizard is skipped.

supports_oauth property

Whether this provider supports OAuth (container-based) auth.

supports_api_key property

Whether this provider supports direct API key entry.

__post_init__()

Validate fields that become filesystem paths.

Source code in src/terok_executor/credentials/auth.py
def __post_init__(self) -> None:
    """Validate fields that become filesystem paths."""
    p = Path(self.host_dir_name)
    if p.is_absolute() or ".." in p.parts or len(p.parts) != 1:
        raise ValueError(
            f"host_dir_name must be a single directory segment, got {self.host_dir_name!r}"
        )

AuthKeyConfig(label, key_url, env_var, config_path, printf_template, tool_name) dataclass

Describes how to prompt for and store an API key.

label instance-attribute

Human name shown in the prompt (e.g. "Claude").

key_url instance-attribute

URL where the user can obtain the key.

env_var instance-attribute

Name shown in the read -p prompt (e.g. "ANTHROPIC_API_KEY").

config_path instance-attribute

Destination inside the container (e.g. "~/.claude/config.json").

printf_template instance-attribute

printf format string (e.g. '{"api_key": "%s"}').

tool_name instance-attribute

Name shown in the success message (e.g. "claude").

Authenticator(provider) dataclass

Vendor-credential acquisition for a single agent.

Wraps the authenticate flow behind a stable class so callers that orchestrate a multi-step setup (terok project init, the standalone terok-executor auth command, the TUI auth flow) talk to one named surface bound to self.provider.

The discovery counterparts (list_authenticated_agents, scan_leaked_credentials) stay as module-level fns in their owning submodules — folding them in here would create a tach cycle through terok_executor.acp and terok_executor.credentials.vault_commands, which already depend on this module transitively.

provider instance-attribute

Auth provider name (e.g. "claude").

run(project_id, *, mounts_dir, image=None, expose_token=False, oauth_enabled=True, credential_set='default')

Run the auth flow for self.provider; see module-level docs.

Mirrors the parameters of the underlying authenticate free function — instance-bound self.provider replaces the old positional provider arg.

Source code in src/terok_executor/credentials/auth.py
def run(
    self,
    project_id: str | None,
    *,
    mounts_dir: Path,
    image: str | Callable[[], str] | None = None,
    expose_token: bool = False,
    oauth_enabled: bool = True,
    credential_set: str = "default",
) -> None:
    """Run the auth flow for ``self.provider``; see module-level docs.

    Mirrors the parameters of the underlying ``authenticate`` free
    function — instance-bound ``self.provider`` replaces the old
    positional ``provider`` arg.
    """
    authenticate(
        project_id,
        self.provider,
        mounts_dir=mounts_dir,
        image=image,
        expose_token=expose_token,
        oauth_enabled=oauth_enabled,
        credential_set=credential_set,
    )

prepare_oauth(project_id, *, mounts_dir, image, expose_token=False, credential_set='default')

Build an AuthSession without running it.

Frontends that own their own UI loop (e.g. the terok Textual TUI, which wants to dispatch the OAuth container into a new terminal tab or via tmux instead of inline) build the session here, run session.argv however they like, then call session.capture() on success. The CLI's blocking authenticate path is just another such caller — see _run_auth_container.

Source code in src/terok_executor/credentials/auth.py
def prepare_oauth(
    self,
    project_id: str | None,
    *,
    mounts_dir: Path,
    image: str,
    expose_token: bool = False,
    credential_set: str = "default",
) -> AuthSession:
    """Build an [`AuthSession`][terok_executor.AuthSession] without running it.

    Frontends that own their own UI loop (e.g. the terok Textual TUI,
    which wants to dispatch the OAuth container into a new terminal
    tab or via tmux instead of inline) build the session here, run
    ``session.argv`` however they like, then call ``session.capture()``
    on success.  The CLI's blocking ``authenticate`` path is just
    another such caller — see ``_run_auth_container``.
    """
    info = AUTH_PROVIDERS.get(self.provider)
    if not info:
        available = ", ".join(AUTH_PROVIDERS)
        raise SystemExit(f"Unknown auth provider: {self.provider}. Available: {available}")
    if not info.supports_oauth:
        raise SystemExit(
            f"Provider {self.provider!r} does not support OAuth — use store_api_key() instead."
        )
    return prepare_oauth_session(
        info,
        project_id,
        mounts_dir=mounts_dir,
        image=image,
        expose_token=expose_token,
        credential_set=credential_set,
    )

AuthSession(provider, project_id, container_name, argv, banner, auth_dir, mounts_dir, credential_set='default', expose_token=False, _tmpdir=None) dataclass

A prepared-but-not-run OAuth auth container session.

Built by Authenticator.prepare_oauth (or the module-level prepare_oauth_session helper). Hold-don't-call: the caller is responsible for running argv (synchronously, in a new terminal tab, suspended TUI, etc.) and calling capture() afterwards. Use as a context manager so the temp dir and any dangling container are cleaned up on exit.

provider instance-attribute

Provider descriptor (label, banner hint, mount points).

project_id instance-attribute

Project scope for the banner; None for host-wide auth.

container_name instance-attribute

Podman container name (used for cleanup and -it log clarity).

argv instance-attribute

The podman run … command line — run this however you like.

banner instance-attribute

Banner text to display before launching argv.

auth_dir instance-attribute

Temp dir bind-mounted as the container's auth config target.

Lives until cleanup() (or __exit__). Credential extraction in capture() reads from here, so don't remove it manually.

mounts_dir instance-attribute

Base directory for the shared post-capture mount (OAuth providers only).

credential_set = 'default' class-attribute instance-attribute

Which credential set in the vault DB receives the captured token.

expose_token = False class-attribute instance-attribute

When True, real credential files are copied into the shared mount (tier 3).

title property

Short human-readable title ("Authenticating Claude (host-wide)").

capture()

Extract credentials from auth_dir, store them in the vault DB.

Call after argv exits successfully. Safe to call multiple times (the underlying extractor is idempotent on a stable credential file).

Source code in src/terok_executor/credentials/auth.py
def capture(self) -> None:
    """Extract credentials from ``auth_dir``, store them in the vault DB.

    Call after ``argv`` exits successfully.  Safe to call multiple
    times (the underlying extractor is idempotent on a stable
    credential file).
    """
    _capture_credentials(
        self.provider.name,
        self.auth_dir,
        self.credential_set,
        mounts_base=self.mounts_dir,
        auth_provider=self.provider,
        expose_token=self.expose_token,
    )

cleanup()

Release the temp dir and force-remove any lingering container.

Idempotent. __exit__ calls this automatically.

Source code in src/terok_executor/credentials/auth.py
def cleanup(self) -> None:
    """Release the temp dir and force-remove any lingering container.

    Idempotent.  ``__exit__`` calls this automatically.
    """
    subprocess.run(
        ["podman", "rm", "-f", self.container_name],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        check=False,
    )
    if self._tmpdir is not None:
        self._tmpdir.cleanup()
        self._tmpdir = None

__enter__()

Return self; the heavy lifting already happened in the factory.

Source code in src/terok_executor/credentials/auth.py
def __enter__(self) -> AuthSession:
    """Return self; the heavy lifting already happened in the factory."""
    return self

__exit__(*_exc)

Run cleanup on context-manager exit.

Source code in src/terok_executor/credentials/auth.py
def __exit__(self, *_exc: object) -> None:
    """Run ``cleanup`` on context-manager exit."""
    self.cleanup()

authenticate(project_id, provider, *, mounts_dir, image=None, expose_token=False, oauth_enabled=True, credential_set='default')

Run the auth flow for provider, optionally scoped to a project.

Dispatches based on the effective mode set — what the provider declares in the roster (modes:) intersected with what the caller has actually permitted via oauth_enabled:

  • api_key only (or OAuth disabled by gate): prompt for key, store directly
  • oauth only: launch container with vendor CLI
  • both: ask the user to choose, then dispatch accordingly

Parameters:

Name Type Description Default
project_id str | None

Project identifier used for container naming and the banner line. Pass None for host-wide auth — the banner drops the project reference and the container gets a neutral host-auth-<provider> name.

required
provider str

Auth provider name (e.g. "claude").

required
mounts_dir Path

Base directory for shared config bind-mounts.

required
image str | Callable[[], str] | None

Container image for the OAuth container. Either a tag string (eager) or a zero-arg callable returning the tag (lazy — invoked only when the user actually chooses the OAuth path). None is fine for API-key-only providers, where no container is launched. Use the lazy form to avoid paying the L1 build cost when the user might pick API key from the OAuth-or-API-key prompt.

None
expose_token bool

When True, copy the real credential files into the shared mount instead of writing a phantom marker. Used by tier 3 (expose_oauth_token) where containers need the actual token.

False
oauth_enabled bool

External gate for the OAuth path. True (default) means the roster's modes list is honored verbatim. False instructs the function to skip the OAuth prompt and go straight to the API-key flow regardless of what the roster declares — terok passes False for providers whose OAuth path requires unset config flags (e.g. agent.codex.allow_oauth=true plus experimental: true). When the provider declares only OAuth and the gate is closed, raises SystemExit with a clear hint.

True
credential_set str

Storage namespace in the vault DB. Defaults to "default" — the shared host-wide bucket every standalone and pre-existing terok caller uses. Per-project callers pass a project-specific value (e.g. project.id) to keep each project's tokens isolated. The DB schema keys on (credential_set, provider), so two projects can hold independent logins for the same provider side-by-side.

'default'

Raises SystemExit if the provider name is unknown or no usable auth mode remains after gating.

Source code in src/terok_executor/credentials/auth.py
def authenticate(
    project_id: str | None,
    provider: str,
    *,
    mounts_dir: Path,
    image: str | Callable[[], str] | None = None,
    expose_token: bool = False,
    oauth_enabled: bool = True,
    credential_set: str = "default",
) -> None:
    """Run the auth flow for *provider*, optionally scoped to a project.

    Dispatches based on the *effective* mode set — what the provider
    declares in the roster (``modes:``) intersected with what the
    caller has actually permitted via *oauth_enabled*:

    - **api_key only** (or OAuth disabled by gate): prompt for key, store directly
    - **oauth only**: launch container with vendor CLI
    - **both**: ask the user to choose, then dispatch accordingly

    Args:
        project_id: Project identifier used for container naming and the
            banner line.  Pass ``None`` for host-wide auth — the banner
            drops the project reference and the container gets a neutral
            ``host-auth-<provider>`` name.
        provider: Auth provider name (e.g. ``"claude"``).
        mounts_dir: Base directory for shared config bind-mounts.
        image: Container image for the OAuth container.  Either a tag
            string (eager) or a zero-arg callable returning the tag
            (lazy — invoked only when the user actually chooses the
            OAuth path).  ``None`` is fine for API-key-only providers,
            where no container is launched.  Use the lazy form to avoid
            paying the L1 build cost when the user might pick API key
            from the OAuth-or-API-key prompt.
        expose_token: When True, copy the real credential files into
            the shared mount instead of writing a phantom marker.  Used
            by tier 3 (``expose_oauth_token``) where containers need
            the actual token.
        oauth_enabled: External gate for the OAuth path.  ``True``
            (default) means the roster's ``modes`` list is honored
            verbatim.  ``False`` instructs the function to skip the
            OAuth prompt and go straight to the API-key flow regardless
            of what the roster declares — terok passes ``False`` for
            providers whose OAuth path requires unset config flags
            (e.g. ``agent.codex.allow_oauth=true`` plus ``experimental:
            true``).  When the provider declares only OAuth and the
            gate is closed, raises ``SystemExit`` with a clear hint.
        credential_set: Storage namespace in the vault DB.  Defaults to
            ``"default"`` — the shared host-wide bucket every standalone
            and pre-existing terok caller uses.  Per-project callers
            pass a project-specific value (e.g. ``project.id``) to
            keep each project's tokens isolated.  The DB schema keys on
            ``(credential_set, provider)``, so two projects can hold
            independent logins for the same provider side-by-side.

    Raises ``SystemExit`` if the provider name is unknown or no usable
    auth mode remains after gating.
    """
    info = AUTH_PROVIDERS.get(provider)
    if not info:
        available = ", ".join(AUTH_PROVIDERS)
        raise SystemExit(f"Unknown auth provider: {provider}. Available: {available}")

    # Gating: a provider's roster may declare OAuth, but the deployment
    # may not allow it (terok's ``allow_oauth`` + ``experimental`` gate).
    has_oauth = info.supports_oauth and oauth_enabled
    has_api_key = info.supports_api_key

    if has_oauth and has_api_key:
        # Both modes — let the user choose first; only resolve the image
        # (and trigger any on-demand L1 build) if the user picks OAuth.
        print(f"Authenticate {info.label}:\n")
        print("  1. OAuth / interactive login (launches container)")
        print("  2. API key (paste key, no container needed)")
        print()
        choice = input("Choose [1/2]: ").strip()
        if choice == "2":
            key = _prompt_api_key(info)
            store_api_key(provider, key, credential_set=credential_set)
            return
        _run_auth_container(
            project_id,
            info,
            mounts_dir=mounts_dir,
            image=_resolve_image(image, provider),
            expose_token=expose_token,
            credential_set=credential_set,
        )

    elif has_api_key:
        # API key only — fast path, no container, image never resolved.
        # Reaches here either because the provider declares only api_key,
        # or because the OAuth gate is closed.
        key = _prompt_api_key(info)
        store_api_key(provider, key, credential_set=credential_set)

    elif has_oauth:
        # OAuth only — image is required.
        _run_auth_container(
            project_id,
            info,
            mounts_dir=mounts_dir,
            image=_resolve_image(image, provider),
            expose_token=expose_token,
            credential_set=credential_set,
        )

    else:
        # Provider declares only OAuth and the caller's gate is closed.
        raise SystemExit(
            f"Auth for {provider!r} requires OAuth, but it is disabled by "
            f"the caller's gating policy.  For terok this typically means "
            f"the experimental flag and/or the provider-specific "
            f"allow_oauth/expose_oauth_token config keys are unset."
        )

store_api_key(provider, api_key, credential_set='default')

Store an API key directly in the credential DB (no container needed).

This is the non-interactive fast path for automated workflows and CI. The key is stored as {"type": "api_key", "key": "<value>"}.

Source code in src/terok_executor/credentials/auth.py
def store_api_key(
    provider: str,
    api_key: str,
    credential_set: str = "default",
) -> None:
    """Store an API key directly in the credential DB (no container needed).

    This is the non-interactive fast path for automated workflows and CI.
    The key is stored as ``{"type": "api_key", "key": "<value>"}``.
    """
    from terok_executor.integrations.sandbox import SandboxConfig

    cfg = SandboxConfig()
    db = cfg.open_credential_db(prompt_on_tty=True)
    try:
        db.store_credential(credential_set, provider, {"type": "api_key", "key": api_key})
        print(f"API key stored for {provider} (set: {credential_set})")
    finally:
        db.close()

prepare_oauth_session(provider, project_id, *, mounts_dir, image, expose_token=False, credential_set='default')

Build an AuthSession without running it.

Creates a fresh temp dir, computes the podman run argv, and cleans up any leftover container of the same name (so re-auth after a previous abort isn't blocked). The caller drives execution and credential capture; see AuthSession.

The temp dir uses a clean slate so the vendor auth flow re-runs end to end — no stale config, no cached sessions.

Source code in src/terok_executor/credentials/auth.py
def prepare_oauth_session(
    provider: AuthProvider,
    project_id: str | None,
    *,
    mounts_dir: Path,
    image: str,
    expose_token: bool = False,
    credential_set: str = "default",
) -> AuthSession:
    """Build an [`AuthSession`][terok_executor.AuthSession] without running it.

    Creates a fresh temp dir, computes the ``podman run`` argv, and
    cleans up any leftover container of the same name (so re-auth
    after a previous abort isn't blocked).  The caller drives execution
    and credential capture; see [`AuthSession`][terok_executor.AuthSession].

    The temp dir uses a clean slate so the vendor auth flow re-runs end
    to end — no stale config, no cached sessions.
    """
    _check_podman()

    tmpdir = tempfile.TemporaryDirectory(prefix=f"terok-auth-{provider.name}-")
    host_dir = Path(tmpdir.name)

    # ``project_id`` must lead the container name; Podman rejects names
    # starting with ``_`` or other non-alphanumeric chars, so the
    # host-wide caller passes ``None`` and we fall back to ``host``.
    name_prefix = project_id or "host"
    container_name = f"{name_prefix}-auth-{provider.name}"
    _cleanup_existing_container(container_name)

    cmd = ["podman", "run", "--rm", *podman_userns_args(), "-it"]
    if provider.extra_run_args:
        cmd.extend(provider.extra_run_args)
    cmd.extend(["-v", f"{host_dir}:{provider.container_mount}:Z"])
    cmd.extend(["--name", container_name])
    cmd.append(image)
    cmd.extend(provider.command)

    scope = f"for project: {project_id}" if project_id else "(host-wide)"
    banner_lines = [
        f"Authenticating {provider.label} {scope}",
        "",
        *provider.banner_hint.splitlines(),
        "",
        f"$ {' '.join(map(str, cmd))}",
        "",
    ]

    return AuthSession(
        provider=provider,
        project_id=project_id,
        container_name=container_name,
        argv=cmd,
        banner="\n".join(banner_lines),
        auth_dir=host_dir,
        mounts_dir=mounts_dir,
        credential_set=credential_set,
        expose_token=expose_token,
        _tmpdir=tmpdir,
    )

api_key_command(cfg)

Build a bash command that prompts for an API key and writes it to a config file.

Source code in src/terok_executor/credentials/auth.py
def api_key_command(cfg: AuthKeyConfig) -> list[str]:
    """Build a bash command that prompts for an API key and writes it to a config file."""
    config_dir = cfg.config_path.rsplit("/", 1)[0]
    parts = [
        f"echo 'Enter your {cfg.label} API key (get one at {cfg.key_url}):'",
        f"read -r -p '{cfg.env_var}=' api_key",
        f"mkdir -p {config_dir}",
        f"printf '{cfg.printf_template}\\n' \"$api_key\" > {cfg.config_path}",
        "echo",
        f"echo 'API key saved to {cfg.config_path}'",
        f"echo 'You can now use {cfg.tool_name} in task containers.'",
    ]
    return ["bash", "-c", " && ".join(parts)]