Registry Configurations — Design
Status: Open — design proposal for the roadmap item Registry configurations.
Problem
Running jackin against a role whose published_image lives in a private registry fails silently and expensively. The operator sees only:
jackin: warning: docker pull docker.io/chainargos/jackin-agent-brown:latest failed (pulling image docker.io/chainargos/jackin-agent-brown:latest); treating published image as stale and rebuilding from workspace Dockerfiledocker pull of the same reference confirms the cause is authentication, not staleness:
Error response from daemon: pull access denied for chainargos/jackin-agent-brown, repository does not exist or may require 'docker login'jackin never sends registry credentials when pulling. The pull path in crates/jackin-docker/src/docker_client.rs calls bollard create_image with the RegistryAuth argument hard-coded to None. Because the freshness check in crates/jackin-runtime/src/runtime/image.rs treats any pull failure as "image is stale", an auth failure is indistinguishable from a genuinely outdated image — so jackin falls through to a full workspace rebuild from the Dockerfile (13+ minutes in the report above) and never tells the operator the real problem was a missing login.
Root cause — the bug class, not the missing argument
The auth: None argument is the symptom. The structural cause is that jackin has no place for an operator to declare credentials for an external endpoint. There is no [registry]/[[registries]] block in the config schema (crates/jackin-config/src/schema.rs defines AppConfig and WorkspaceConfig with auth blocks only for the agent API key and the GitHub token). Because there is no general credential-source layer, every external secret jackin has ever needed was solved as a one-off:
- Agent API keys and GitHub token — bespoke fields in
crates/jackin-config/src/auth.rs. - 1Password / env references — resolved ad hoc through the host-socket daemon in
crates/jackin-capsule/src/exec.rs. - A general
CredentialSourceabstraction was designed but never built.
Registry pull-auth is simply the next instance of this class. A correct architecture would have absorbed it without a new code path. Patching only create_image would leave the enabling condition — no credential-source layer, no per-endpoint configuration surface — in place, and the next external endpoint (a second ECR account, a private GHCR, a proxy) would reproduce the same gap.
Operator-shaped requirements
- This is not "authentication" and must not surface as an auth tab. It is a configurations surface: credentials are one attribute of a configured endpoint, not the headline concept.
- An operator must be able to configure multiple endpoints at once, each with its own credentials — e.g. an ECR account, a private Docker Hub org, and a GHCR namespace simultaneously.
- Configurations must be declarable at the global level and per workspace, with per-workspace entries overriding/extending global. This maps onto the existing
AppConfig(global) /WorkspaceConfig(per-workspace) split — no new scoping machinery required. - The forcing case — and first real consumer — is Amazon ECR private registries, whose 12-hour tokens make a static stored secret insufficient and force the credential-source layer to support refreshable/command-sourced credentials, not just literals.
Why it matters
- Correctness over silent fallback. An auth failure currently masquerades as staleness and triggers a multi-minute rebuild with no actionable message. The operator cannot tell that one
docker login-equivalent step would have fixed it. - Private images are table stakes. Teams ship their role/agent images to private ECR, GHCR, or Docker Hub repos. Without pull auth, the published-image fast path is unusable for them and every launch pays the full local-build cost.
- Removes a recurring bug class. A single credential/configuration layer means the next external endpoint plugs in instead of growing another bespoke field. It also gives
CredentialSourcea concrete first consumer, turning a stalled proposal into shipped infrastructure. - Consolidates a scattered surface. Registry auth, agent tokens, the GitHub token, and 1Password references are today separate concepts in separate places. A configurations layer is where they converge, and it interlocks with container credential exposure (resolved secrets must not leak into image layers,
docker inspect, or logs) and the workspace registry cache (a pull-through cache still needs upstream credentials).
Design
The structural move is a configurations layer: a list of named, scoped endpoint configurations, each carrying a credential sourced through the CredentialSource enum (literal, env, command, op://, OS keychain, and — new — Docker credential store/helper and an ECR provider). Registry pull-auth is the first consumer: resolve the matching configuration for a published_image reference, hand bollard real credentials, and distinguish auth failure from staleness so the operator gets an actionable message instead of a silent rebuild.
Pull through bollard, not the docker CLI
The pull path must stay in-process via the bollard API, not shell out to the docker CLI. The CLI offers no way to hold a session or inject ad-hoc credentials per pull; bollard does, and jackin already calls it. This was verified against the pinned bollard 0.21.0:
create_image(options, body, credentials: Option<DockerCredentials>)— the third argument is the injection point. Today it isNoneincrates/jackin-docker/src/docker_client.rs. bollard base64-encodes the credentials into theX-Registry-Authheader itself — the same mechanism the CLI uses internally.bollard::auth::DockerCredentials { username, password, auth, email, serveraddress, identitytoken, registrytoken }— allOption<String>. (Note: the type isDockerCredentials, notRegistryAuth.)
So the only missing piece is producing a DockerCredentials and passing it. No architecture change, no CLI dependency.
Resolve credentials lazily, per pull — not at config load
This is the key move that makes "authenticate once, refresh transparently" fall out for free. A provider trait, resolved at the moment of pull:
#[async_trait]
trait RegistryAuthProvider {
// `registry` is the host parsed from the image ref,
// e.g. "123456789.dkr.ecr.us-east-1.amazonaws.com"
async fn credentials(&self, registry: &str) -> anyhow::Result<Option<DockerCredentials>>;
}pull_image(image) parses the host from the reference, asks the provider, and hands bollard fresh DockerCredentials. Because resolution happens at pull time, a refreshed token is simply "the provider returned a newer value this call." Resolving at startup instead would capture a token that expires mid-session — the failure mode to avoid.
Provider variants map onto CredentialSource
| Kind | Source | Refresh |
|---|---|---|
static | username/password from env / op:// / config literal | none needed (e.g. a GHCR or Docker Hub PAT) |
docker-config | read ~/.docker/config.json via the docker_credential crate — honors credsStore/credHelpers | credential helper re-invoked on each read → free refresh when docker-credential-ecr-login is installed |
ecr (native) | aws-sdk-ecr get_authorization_token over the default AWS chain (profile / SSO / instance role / IRSA) | token carries expires_at (~12h) → cache and refresh on expiry skew |
ECR token decode: the returned authorizationToken is base64 of AWS:<password>. Set username = Some("AWS"), password = Some(decoded), serveraddress = Some(registry).
Refresh = cache-with-expiry inside the provider
struct EcrProvider {
ecr: aws_sdk_ecr::Client,
cache: Mutex<HashMap<String, (DockerCredentials, SystemTime)>>,
}
// credentials(): if cached and now < expiry - skew → reuse;
// else call get_authorization_token, store { creds, expires_at }."Authenticate once" for ECR means the operator configures the AWS profile/SSO once (or runs on a role-bearing instance/IRSA); jackin mints fresh ~12h tokens on demand, transparently, with no re-login and — with the native aws-sdk-ecr path — no helper binary to install. Static credentials (PATs) need no refresh at all. This is the heart of the desired UX: provide credentials once, never be forced to re-authenticate mid-flow.
Recommended default + escape hatches
- Default (zero config): the
docker-configprovider. If the operator has already rundocker loginor installed the ECR credential helper, jackin works with no jackin-side configuration — this is "provide once," reusing the host's existing login. - Native
ecrconfig entry: best UX for the private-ECR forcing case — AWS credential chain plus automatic refresh, no helper install. Recommended for the reported failure. - Explicit
[[registries]]entries: env /op:/// command credential sources for everything else, declarable global or per workspace.
Concept-validation PoC
Before committing to the design, a ~30-line throwaway (no jackin integration) proves the whole approach: obtain an ECR token (aws ecr get-login-password or aws-sdk-ecr), build a DockerCredentials { username: "AWS", password: token, serveraddress: <registry>, .. }, and call create_image(.., Some(creds)) to pull a private ECR image. If that pulls clean, bollard + in-process credentials + per-pull resolution is validated end to end.
Decided: config surface — narrow surface, general guts
The first cut is a registry-specific TOML block, but each entry's credential field is the shared CredentialSource enum — concrete surface, general internals. This ships the ECR fix without over-designing a universal endpoint layer, and the credentials value generalizes for free. Why: avoids speculative [[configurations]] schema risk while still being DRY; a broader endpoint/connections layer can wrap this later without reshaping the credential guts.
# global config.toml
[[registries]]
name = "prod-ecr"
match = "*.dkr.ecr.us-east-1.amazonaws.com"
credentials = { ecr = { region = "us-east-1", profile = "prod" } }
[[registries]]
name = "ghcr"
match = "ghcr.io"
credentials = { op = "op://vault/ghcr/token" }
# per-workspace entries extend/override global
[workspaces.acme]
[[workspaces.acme.registries]]
name = "acme-ecr"
match = "*.dkr.ecr.eu-west-1.amazonaws.com"
credentials = { ecr = { region = "eu-west-1" } }Each entry: name (operator label), match (host pattern for resolving an image ref to an entry), credentials (a CredentialSource). Declarable in global AppConfig and per-workspace WorkspaceConfig, reusing the existing scope split.
Decided: zero-config default — auto-read host docker config
When an image reference matches no [[registries]] entry, jackin falls back to the host's ~/.docker/config.json, honoring credsStore and credHelpers (via the docker_credential crate). Why: this is exactly how docker, buildkit, and skopeo already behave, so it is least-surprise; an operator who has run docker login or installed docker-credential-ecr-login gets working private pulls with zero jackin configuration — the "provide once" promise. Reading ~/.docker/config.json is a host read, allowed under HOST_AND_CONTAINER.md (no opt-in needed; no host writes). Resolution stays host-side and credentials are handed only to the docker daemon via bollard's auth header — they never enter the container env or image layers (container credential exposure).
Precedence: an explicit matching [[registries]] entry wins over the host-config fallback; a per-workspace entry wins over a global entry for the same host.
Decided: separate auth failure from staleness — fail fast, opt-in fallback
The root pain is that image.rs's published_image_freshness maps every pull error to Stale, so an auth failure silently triggers a multi-minute rebuild. The fix splits the conflated states: a pull failure is classified, not assumed stale.
- Auth failure (HTTP 401/403,
pull access denied,requires 'docker login',no basic auth credentials,unauthorized) → by default fail fast with an actionable message naming the registry and the fix (add a[[registries]]entry /docker login/ install the ECR helper). No rebuild — rebuilding masks a one-line config error, and the construct base image may itself be private and also unpullable. Why default-fail: correctness over a silent expensive fallback that hides the real problem. - Manifest/tag not found (
manifest unknown, repo reachable but no such tag) → genuinely no published image → fall back to building from the workspace Dockerfile, as today. This is the one legitimate "stale/missing" case. - Network / transient → surfaced as an error (not masked as stale); retry policy TBD.
Escape hatch — a global key for operators who legitimately lack registry access and want the local build:
[registry]
on_auth_failure = "fail" # default; "build" falls back to the workspace DockerfileDecided: v1 credential kinds
The CredentialSource members built in this item:
| Kind | v1 | Notes |
|---|---|---|
docker-config | yes | implicit fallback; reads ~/.docker/config.json + helpers |
ecr | yes | native, forcing case; AWS chain + auto-refresh |
op | yes | reuses the shipped host-socket 1Password resolution (exec.rs) |
env | yes | named env var; essential for CI/headless |
static (literal user/pass) | yes | plaintext-on-disk — docs must discourage beyond local testing (registry-credential plaintext is the GHSA-99pg-grm5-qq3v pitfall); never log/echo it |
command | deferred | generic refresh escape hatch; add post-v1 if a custom-SSO need appears |
Decided: single injection point, host-side resolution
One BollardDockerClient (host daemon via DOCKER_HOST/defaults, docker_client.rs) drives every pull — the published-image freshness check (image.rs) and the DinD sidecar image pull (launch_dind.rs). So credential resolution threads into pull_image itself — a single injection point that covers role images and the DinD base image (which may also be private). Why this is clean: creds are produced host-side and handed only to the docker daemon via bollard's X-Registry-Auth header; they never cross into a container's env or image layers, satisfying container-credential-exposure goals without extra plumbing. The provider is constructed once and passed down to the docker client.
Decided: ECR provider mechanics
The native ecr kind uses aws-sdk-ecr get_authorization_token over the default AWS credential provider chain — env vars, shared ~/.aws profile (profile field selects it), SSO (cached from a prior aws sso login), ECS container creds, EC2 IMDS instance role, and EKS web-identity/IRSA. This means headless/CI works with no interactive step whenever the environment carries any chain leg (env keys, instance role, IRSA); only SSO needs a prior login, which is the AWS-standard flow. Config:
credentials = { ecr = { region = "us-east-1", profile = "prod" } }region may be derived from the registry host (<acct>.dkr.ecr.<region>.amazonaws.com) so it is optional when the match host carries it; profile is optional (default chain otherwise). Token caching: store { DockerCredentials, expires_at } per registry, refresh when within a small skew (~5 min) of expires_at. This is the "authenticate once" payoff — operator configures AWS once; jackin mints ~12h tokens on demand.
Decided: jackin registry login (keychain-backed)
Ship a jackin registry login <host> command for discoverable first-run setup. Constraints that shape it:
- No plaintext store. It must not write a jackin-owned plaintext credential file (the GHSA-99pg-grm5-qq3v pitfall). It stores into the OS keychain / docker
credsStorebackend — the same secure storedocker loginuses — so it is a friendly front-end to the standard store, not a second credential home. This keeps it DRY with thedocker-configread path: whatloginwrites, the default fallback reads back. - Host-write opt-in is satisfied by the explicit invocation. Per
HOST_AND_CONTAINER.mdthe no-silent-host-writes rule targets silent writes during launch; an operator runningjackin registry loginis explicit consent. The launch path still performs no credential writes. - Complements, does not replace. Setup options remain: existing
docker login,[[registries]]config entries (env/op/ecr/static), or this command. For ECR specifically the nativeecrconfig kind is still the lower-friction path (no per-12h login);loginis aimed at static-credential registries (GHCR/Docker Hub PATs).
A read-only jackin registry status (which registries resolve, from which source, ECR token expiry) is a natural companion for visibility and pairs with the auth health/visibility theme — include if cheap, else note as follow-up.
Open design questions
- Network/transient pull-error retry policy.
commandcredential kind (post-v1).- Exact
registry statusscope and whether it lands in v1. - Multi-match tie-breaking precision (longest-host-pattern wins) and
matchglob syntax. - Naming of the entry type (
registriesvs a broaderendpoints/connections/configurations) and whether the first cut is registry-only or born general. - Reuse-vs-build for the mechanics: bollard for the pull is settled; for sourcing, choose between the
docker_credentialcrate, nativeaws-sdk-ecr, shelling out to the credential helper, and (if pulls ever move off bollard)oci-client/rust-oci-client— per the "prefer maintained crates" rule. - How the
ecrprovider behaves in headless/CI environments (which leg of the AWS chain applies) and how expiry skew is tuned. - Whether to also offer a
jackin registry logincommand, or rely entirely on host docker login + config entries. - Where resolution happens relative to the host-socket daemon, so credentials never enter the container env or image layers (ties to container credential exposure).
Research sources
Sources gathered and individually verified during scoping (the automated synthesis pass degenerated and produced no usable prose summary; these primary/secondary sources are the durable output and should be mined directly during implementation).
ECR auth flows & token-refresh pain:
Docker credential storage model:
Comparable tools:
Security pitfalls:
Rust library options:
Expected code touchpoints
crates/jackin-docker/src/docker_client.rs—pull_image; bollardcreate_imagewithRegistryAuthcurrentlyNone.crates/jackin-runtime/src/runtime/image.rs— published-image freshness check that conflates auth failure with staleness.crates/jackin-config/src/schema.rs—AppConfig/WorkspaceConfig; where a configurations block would live.crates/jackin-config/src/auth.rs— existing bespoke auth blocks (agent key, GitHub token).crates/jackin-core/src/manifest.rs—RoleManifest.published_image.crates/jackin-capsule/src/exec.rs— host-socket credential resolution in use today.
Related work
- Roadmap: Registry configurations
- Credential Source Pattern — Design — the unified sourcing abstraction this item makes concrete and consumes.
- Container Credential Exposure — Beyond Env Injection — adjacent design on how resolved credentials reach the container, not how they're sourced.
- Workspace registry cache — a pull-through cache still needs upstream registry credentials.
- 1Password integration — one credential source among several.