Skip to content

Auth

Programmatic credential management for Docker containers. See the Docker credentials guide for the full workflow and the caw auth CLI.

These three functions are re-exported at the top level of caw (as auth_setup, auth_get_status, auth_get_docker_flags) and also live in caw.auth.

setup

setup

setup(agents: list[str] | None = None, source_home: str | None = None, dest_dir: str | Path | None = None) -> Path

Snapshot credentials and cleaned configs into an auth directory.

Host credential files are read but not modified. At container run time they are bind-mounted into the same paths under the mount point — see caw.auth.get_docker_flags.

Parameters:

Name Type Description Default
agents list[str] | None

List of agent names, or None / ["all"] for all agents.

None
source_home str | None

Home directory to read credentials from.

None
dest_dir str | Path | None

Custom destination directory. Defaults to ~/.caw/auth/.

None

Returns:

Type Description
Path

Path to the auth directory.

Source code in caw/auth/collector.py
def setup(
    agents: list[str] | None = None,
    source_home: str | None = None,
    dest_dir: str | Path | None = None,
) -> Path:
    """Snapshot credentials and cleaned configs into an auth directory.

    Host credential files are read but not modified. At container run time
    they are bind-mounted into the same paths under the mount point — see
    `caw.auth.get_docker_flags`.

    Args:
        agents: List of agent names, or None / ["all"] for all agents.
        source_home: Home directory to read credentials from.
        dest_dir: Custom destination directory. Defaults to ~/.caw/auth/.

    Returns:
        Path to the auth directory.
    """
    src_home = Path(source_home) if source_home else Path.home()
    auth_dir = Path(dest_dir) if dest_dir else default_auth_dir()

    console.print(f"[bold]Collecting credentials into {auth_dir}/[/bold]\n")

    # Resolve providers
    selected = _resolve_providers(agents or ["all"])

    # Validate
    valid_providers, skipped_providers = _validate_providers(selected, src_home)

    if not valid_providers:
        console.print("[red]Error: No valid agent credentials found.[/red]")
        for provider, missing in skipped_providers:
            console.print(f"  [dim]{provider.name}:[/dim] missing {', '.join(missing)}")
        raise SystemExit(1)

    # Warn about missing agents
    if skipped_providers:
        for provider, missing in skipped_providers:
            console.print(f"[yellow]Warning: {provider.name} credentials not found:[/yellow] {', '.join(missing)}")
        console.print()

    # Show what we'll process
    names = [p.name for p in valid_providers]
    console.print(f"[dim]Agents:[/dim] {', '.join(names)}\n")

    # Describe each provider
    for provider in valid_providers:
        desc = provider.describe(src_home)
        console.print(f"[bold]{provider.name}:[/bold] {desc}")
    console.print()

    # Collect files from all providers
    all_files: list[CollectedFile] = []
    manifest = Manifest.create(host_home=str(src_home))

    for provider in valid_providers:
        console.print(f"[bold]Collecting {provider.name} files...[/bold]")
        collected = provider.collect(src_home)
        all_files.extend(collected)

        # Build manifest entry
        agent_manifest = AgentManifest(files=[cf.manifest_file for cf in collected])
        manifest.agents[provider.name] = agent_manifest

    # Write everything
    if auth_dir.exists():
        import shutil

        shutil.rmtree(auth_dir)
    auth_dir.mkdir(parents=True)

    # Write collected files
    for cf in all_files:
        out_path = auth_dir / cf.manifest_file.src
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_bytes(cf.content)
        out_path.chmod(int(cf.manifest_file.mode, 8))

    # Write manifest
    manifest.save(auth_dir / "manifest.json")

    # Write setup-container.sh
    setup_script = auth_dir / "setup-container.sh"
    setup_script.write_text(_generate_setup_container_sh(manifest))
    setup_script.chmod(0o755)

    # Set directory permissions
    for d in auth_dir.rglob("*"):
        if d.is_dir():
            d.chmod(0o755)

    console.print("\n[bold green]Done![/bold green]")
    console.print(f"Auth files written to: {auth_dir}")
    console.print("\n[dim]Contents:[/dim]")
    for cf in all_files:
        strategy = cf.manifest_file.strategy
        ftype = cf.manifest_file.type
        console.print(f"  [dim]{cf.manifest_file.src}[/dim]  ({ftype}, {strategy})")
    console.print("  [dim]manifest.json[/dim]")
    console.print("  [dim]setup-container.sh[/dim]")
    console.print(
        "\n[dim]Host credential files are untouched; they will be bind-mounted "
        "into the container at run time. Use `caw auth docker-flags` to get the "
        "full -v flag list.[/dim]"
    )

    return auth_dir

get_status

get_status

get_status(agents: list[str] | None = None, auth_dir: str | Path | None = None) -> list[AuthFileStatus]

Return structured status of all managed auth files.

Credential freshness is read from the host file directly (the source of truth), not from the staged snapshot under auth_dir.

Parameters:

Name Type Description Default
agents list[str] | None

Agent names to include, or None for all.

None
auth_dir str | Path | None

Custom auth directory. Defaults to ~/.caw/auth/.

None

Returns:

Type Description
list[AuthFileStatus]

List of AuthFileStatus for each managed file.

Raises:

Type Description
FileNotFoundError

If the manifest.json doesn't exist in auth_dir.

Source code in caw/auth/status.py
def get_status(
    agents: list[str] | None = None,
    auth_dir: str | Path | None = None,
) -> list[AuthFileStatus]:
    """Return structured status of all managed auth files.

    Credential freshness is read from the host file directly (the source of
    truth), not from the staged snapshot under ``auth_dir``.

    Args:
        agents: Agent names to include, or None for all.
        auth_dir: Custom auth directory. Defaults to ~/.caw/auth/.

    Returns:
        List of AuthFileStatus for each managed file.

    Raises:
        FileNotFoundError: If the manifest.json doesn't exist in auth_dir.
    """
    resolved_dir = Path(auth_dir) if auth_dir else default_auth_dir()
    manifest_path = resolved_dir / "manifest.json"
    if not manifest_path.exists():
        raise FileNotFoundError(f"No manifest.json found at {manifest_path}")

    manifest = Manifest.load(manifest_path)
    agent_names = set(agents) if agents and "all" not in agents else set(manifest.agents.keys())

    results: list[AuthFileStatus] = []
    for agent_name, agent_manifest in manifest.agents.items():
        if agent_name not in agent_names:
            continue

        # Find the credential file for token-expiry lookup (read from host).
        cred_mf = next(
            (mf for mf in agent_manifest.files if mf.type == "credential"),
            None,
        )
        token_info = (
            _check_token_expiry(_backing_path(manifest, resolved_dir, cred_mf), agent_name) if cred_mf else None
        )

        for mf in agent_manifest.files:
            backing = _backing_path(manifest, resolved_dir, mf)
            results.append(
                AuthFileStatus(
                    agent=agent_name,
                    file=mf.host_original,
                    type=mf.type,
                    strategy=mf.strategy,
                    exists=backing.exists(),
                    token_expiry=token_info if mf.type == "credential" else None,
                )
            )

    return results

get_docker_flags

get_docker_flags

get_docker_flags(auth_dir: str | Path | None = None) -> str

Return the Docker -v flags for mounting the auth directory and credentials.

Emits one directory bind mount for the staging area plus one file bind mount per credential, pointing directly at the host's original file. The credentials are never copied out of their original location.

Parameters:

Name Type Description Default
auth_dir str | Path | None

Custom auth directory. Defaults to ~/.caw/auth/.

None

Returns:

Type Description
str

A space-separated string of Docker -v flags, e.g.:

-v /.../.caw/auth:/tmp/caw_auth:rw -v /.../.claude/.credentials.json:/tmp/caw_auth/claude/credentials.json:rw

Raises:

Type Description
FileNotFoundError

If the manifest.json doesn't exist in auth_dir.

Source code in caw/auth/status.py
def get_docker_flags(auth_dir: str | Path | None = None) -> str:
    """Return the Docker ``-v`` flags for mounting the auth directory and credentials.

    Emits one directory bind mount for the staging area plus one file bind
    mount per credential, pointing directly at the host's original file. The
    credentials are never copied out of their original location.

    Args:
        auth_dir: Custom auth directory. Defaults to ~/.caw/auth/.

    Returns:
        A space-separated string of Docker ``-v`` flags, e.g.:

            -v /.../.caw/auth:/tmp/caw_auth:rw \
            -v /.../.claude/.credentials.json:/tmp/caw_auth/claude/credentials.json:rw

    Raises:
        FileNotFoundError: If the manifest.json doesn't exist in auth_dir.
    """
    resolved_dir = Path(auth_dir) if auth_dir else default_auth_dir()
    manifest_path = resolved_dir / "manifest.json"
    if not manifest_path.exists():
        raise FileNotFoundError(f"No manifest.json found at {manifest_path}")

    manifest = Manifest.load(manifest_path)

    flags = [f"-v {resolved_dir}:{manifest.mount_point}:rw"]
    for agent_manifest in manifest.agents.values():
        for mf in agent_manifest.files:
            if mf.strategy != "bind":
                continue
            host_path = _bind_source(manifest, mf)
            container_path = f"{manifest.mount_point}/{mf.src}"
            flags.append(f"-v {host_path}:{container_path}:rw")
    return " ".join(flags)

teardown

teardown

teardown(auth_dir: str | Path | None = None, force: bool = False) -> None

Remove the auth directory. Host credential files are never touched.

Refuses to run if any host credential file is still a symlink into auth_dir (legacy state from the old symlink-based design), since removing the directory would leave dangling symlinks with no backup. Pass force=True to override.

Parameters:

Name Type Description Default
auth_dir str | Path | None

Custom auth directory. Defaults to ~/.caw/auth/.

None
force bool

Delete even if host symlinks point into auth_dir.

False

Raises:

Type Description
TeardownWouldOrphanSymlinksError

If host symlinks point into the auth directory and force is False.

Source code in caw/auth/__init__.py
def teardown(auth_dir: str | Path | None = None, force: bool = False) -> None:
    """Remove the auth directory. Host credential files are never touched.

    Refuses to run if any host credential file is still a symlink into
    ``auth_dir`` (legacy state from the old symlink-based design), since
    removing the directory would leave dangling symlinks with no backup.
    Pass ``force=True`` to override.

    Args:
        auth_dir: Custom auth directory. Defaults to ~/.caw/auth/.
        force: Delete even if host symlinks point into ``auth_dir``.

    Raises:
        TeardownWouldOrphanSymlinksError: If host symlinks point into the
            auth directory and ``force`` is False.
    """
    target = Path(auth_dir) if auth_dir else default_auth_dir()
    if not target.exists():
        return

    if not force:
        dangerous = _find_old_design_symlinks(target)
        if dangerous:
            lines = [f"  {host} -> {t}" for host, t in dangerous]
            raise TeardownWouldOrphanSymlinksError(
                "Refusing to remove "
                f"{target}: host credential files still symlink into it "
                "(leftover from the old symlink-based design). Removing the "
                "directory would leave dangling symlinks and you would need "
                "to re-authenticate every agent.\n\n"
                "Do one of:\n"
                "  1. Replace each symlink with its real file first:\n"
                "     for f in <paths>; do cp --remove-destination "
                '"$(readlink -f "$f")" "$f"; done\n'
                "  2. Call teardown(force=True) if you accept re-auth.\n\n"
                "Affected symlinks:\n" + "\n".join(lines)
            )

    shutil.rmtree(target)

Types

AuthFileStatus dataclass

AuthFileStatus(agent: str, file: str, type: str, strategy: str, exists: bool, token_expiry: str | None)

Status of a single managed auth file.