Skip to content

feat(docker): opt-in gh + az CLIs with git credential helpers so Clone Repo and Docker cases can reach private repos - #472

Merged
Ark0N merged 2 commits into
Ark0N:masterfrom
opticon454:feature/git-host-auth-clis
Sep 23, 2026
Merged

Ark0N merged 2 commits into
Ark0N:masterfrom
opticon454:feature/git-host-auth-clis

Conversation

@opticon454

@opticon454 opticon454 commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Add Case → Clone Repo only works for public repositories in the Docker deployment: the container has no way to authenticate to GitHub or Azure DevOps, so any private URL fails with AUTH_REQUIRED and the message sends the user off to clone it by hand and use Link Existing.

src/git-clone.ts already inherits HOME and git's own config on purpose ("a user whose own credential helper already works keeps working"). This PR lets a Docker deployment opt in to credential helpers for that path to inherit. Codeman itself still collects nothing.

  • Opt-in, off by default. docker/server.Dockerfile and docker/agent.Dockerfile take CODEMAN_INSTALL_GH / CODEMAN_INSTALL_AZ build args (0/1, default 0; anything else stops the build). With neither set, the rebuilt images are functionally unchanged: no repository, package, extension or credential entry. What remains is the AZURE_EXTENSION_DIR variable, an empty directory and one COPY+rm layer. A host turns them on in docker-compose.override.yml:

    services:
      codeman:
        build:
          args:
            CODEMAN_INSTALL_GH: '1'
            CODEMAN_INSTALL_AZ: '1'
        environment:
          CODEMAN_AGENT_IMAGE_INSTALL_GH: '1'   # Docker-case agent image
          CODEMAN_AGENT_IMAGE_INSTALL_AZ: '1'

    docker-compose.yaml is untouched, and .env.example gains only a comment pointing at the override file. A new .env.example key would make the updater's environment gate refuse every existing install until its .env gained it.

  • What an enabled switch installs. The GitHub CLI and/or the Azure CLI with the azure-devops extension, from their vendors' apt repositories: the same repos the documented one-liners configure. deb_install.sh isn't piped into the build, and apt reads the .asc key directly, so gnupg stays out. Plus system gitconfig credential helpers, per host:

    • https://github.com, https://gist.github.com → !/usr/bin/gh auth git-credential (what gh auth setup-git writes).
    • https://dev.azure.com, https://*.visualstudio.com → new docker/git-credential-azure-cli, which returns an Entra ID token from az account get-access-token --resource 499b84ac-… (the Azure DevOps resource id), or AZURE_DEVOPS_EXT_PAT when set. useHttpPath=true for both.
  • Extension location. It goes into AZURE_EXTENSION_DIR, outside HOME. HOME is the app-data bind mount on the server image and a seeded directory on the agent image, and either would hide or drop a build-time install. On the server it's /opt/codeman-az-extensions, chowned to the runtime account next to /opt/codeman-cli; on the agent it's /opt/az-extensions, gid 0 and group-writable. Nothing that runs as root executes from either.

  • Agent image plumbing. scripts/build-agent-image.mjs and the in-app auto-build both pass the switches from CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ, and pass nothing when unset, so a default build's argv is unchanged. They share one env → ARG table, which test/agent-image-build-args-parity.test.ts pins for every combination. A malformed value is refused on both sides; the auto-build reports it as a failed build.

  • Docker-case seeding, behind the same switches. CRED_STORES in src/docker-hosts.ts gains two per-file seeds, read-only and copied once at launch like pi/grok. Each is gated by enabledByEnv and seeded only when its switch is exactly 1 at container create (.config/gh needs CODEMAN_AGENT_IMAGE_INSTALL_GH, .azure needs CODEMAN_AGENT_IMAGE_INSTALL_AZ), never merely because the files exist:

    • .config/gh → hosts.yml, config.yml
    • .azure → azureProfile.json, msal_token_cache.json, service_principal_entries.json, clouds.config, config

    ~/.azure's logs, command index and extensions are never seeded. Seeds are create-time mounts, so an existing case container must be recreated to pick them up. With a switch on, they hand a GitHub token and an Azure sign-in to seeded case containers, the same trust already extended to Claude/Codex/gcloud credentials; a sealed case (seeding off) gets none. A token in a desktop keyring, or in az's encrypted MSAL cache on Windows/macOS, isn't in those files and doesn't carry. That's documented.

  • Signing in. The user signs in once from a Terminal / Shell session (gh auth login, az login --use-device-code). It lands in ~/.config/gh and ~/.azure on the bind mount, so it survives rebuilds.

  • Multi-user mode: no shared git sign-in for non-admins. Every user's git runs as the one server account, so a non-admin's Clone Repo clone and preflight run with git -c credential.helper= (GIT_NO_CREDENTIAL_HELPERS, before the subcommand; decided by cloneWithoutCredentialHelpers(req)). That empties the helper list including the URL-scoped entries (verified against a real private repo), while public repos still clone. Admins and single-user mode keep the helpers. This closes the Clone Repo path only: the account's SSH keys and a non-admin's own agent sessions are unchanged, as documented in security-architecture.md.

  • Fail-fast is unchanged. A helper whose CLI isn't signed in prints nothing, so git falls through to its normal terminal prompts disabled failure (measured: about 0.5 s).

  • Error message. AUTH_REQUIRED's message now says how to sign the server's git in, instead of saying private repositories can't be cloned. classifyGitFailure and its codes are unchanged.

  • Docs:

    • docker/README.md: "Private repositories" with "Turning them on", "Signing in" and "Versions", including gh skill install cli/cli gh --scope user.
    • docs/docker-compose.md and docs/docker-cases.md.
    • The Quick-Start, Core-Concepts and Docker-Cases wiki pages.
    • The credential lists and the multi-user section in security-architecture.md, and architecture-invariants.md.
    • Recreate-the-container guidance in the README, docker-cases.md and the Docker-Cases wiki.

gh, az and the extension are not version-pinned, unlike the four agent CLIs: nothing in Codeman depends on a particular gh or az behaviour. Each build takes the current release, and the layer cache keeps it until a --no-cache rebuild; the README says so. Happy to pin them if you'd rather.

This changes server.Dockerfile, so the updater's environment gate will (correctly) ask Compose users to run Start-Codeman.sh instead of applying it in place; with no switches set, the rebuilt image is functionally unchanged. The changeset is marked minor; adjust as you see fit.

Commits

  • 5cf5a45 feat(docker): opt-in gh + az CLIs with git credential helpers for private repos. The whole feature, squashed from five working commits.
  • 02e40f5 fix(docker): gate gh/az seeding on its switch; no shared git sign-in for non-admin clones. Addresses the review: seeding gate + tests, multi-user credential-helper clearing + argv/route tests, docs.

Verification

Built both Dockerfiles locally (Docker 29.5.3) and checked them as the unprivileged runtime account (server uid 1000; agent --user 12345:0).

Default (no switches). Server and agent images contain no gh, no az, no helper script, no extra apt source and no credential.* entry. build-agent-image.mjs passes no CODEMAN_INSTALL_* args.

Both on (=1).

  • gh version 2.101.0, azure-cli 2.90.0, azure-devops 1.0.8, and the six credential entries.
  • az extension update --name azure-devops works as the runtime user (the directory is writable).
  • Every existing agent CLI is still present in the agent image.

Mixed and invalid.

  • gh only: exactly the two GitHub entries, no az.
  • CODEMAN_INSTALL_AZ=yes: the build stops with CODEMAN_INSTALL_AZ must be 0 or 1.
  • Agent image through build-agent-image.mjs: with CODEMAN_AGENT_IMAGE_INSTALL_*=0 the script passed both args, and the image has neither CLI.

Authentication.

  • Not signed in: git ls-remote against a private github.com URL and a dev.azure.com URL both fail in about 0.5 s with terminal prompts disabled. git-credential-azure-cli get prints nothing and exits 0.
  • GitHub, signed in (GH_TOKEN): git ls-remote of a real private repository returns its refs through gh auth git-credential.
  • Docker-case seeding: a hosts.yml staged at the seed path and copied by the launch's own [ -e to ] || cp from to line lets an agent-image user list a private repo.
  • The AZURE_DEVOPS_EXT_PAT path makes the helper emit username=pat / password=<pat>.
  • Azure DevOps with a live az login was NOT verified end to end. The account I had available needs an interactive MFA re-login. The token-as-basic-auth-password scheme is the one Git Credential Manager uses for Azure Repos, but it hasn't been exercised against dev.azure.com here.

Tests. New unit tests:

  • test/docker-hosts.test.ts: the gh/az seeds are per file, read-only, exclude logs//cliextensions/, are NOT seeded in the default environment even when the host files exist, and each follows only its own switch.
  • test/git-clone.test.ts: the -c credential.helper= argv, before the subcommand, for both clone and ls-remote.
  • test/routes/case-clone-credential-helpers.test.ts: non-admin in multi-user mode cleared; admin and single-user kept.

The switch parity, validation and ARG ...=0 defaults are covered in test/agent-image-build-args-parity.test.ts. Full gate on 02e40f50, full gate (npm test, the CI config) in a Linux container: 415 files / 7840 tests passed, 12 skipped, 0 failed; tsc --noEmit and lint clean.

Image size (server image). Neither 3.67 GB (none of this layer content; I did not build an upstream-master image to compare against), gh only 3.71 GB, both 4.34 GB. The Azure CLI is about 630 MB of the roughly 670 MB the pair adds.

🤖 Generated with Claude Code

https://claude.ai/code/session_0167CiuzLrmjYWxwKp3rMWjw

@opticon454 opticon454 changed the title feat(docker): gh + az CLIs with git credential helpers so Clone Repo can reach private repos feat(docker): gh + az CLIs with git credential helpers so Clone Repo and Docker cases can reach private repos Sep 23, 2026
@opticon454 opticon454 changed the title feat(docker): gh + az CLIs with git credential helpers so Clone Repo and Docker cases can reach private repos feat(docker): optional gh + az CLIs with git credential helpers so Clone Repo and Docker cases can reach private repos Sep 23, 2026
@opticon454 opticon454 changed the title feat(docker): optional gh + az CLIs with git credential helpers so Clone Repo and Docker cases can reach private repos feat(docker): opt-in gh + az CLIs with git credential helpers so Clone Repo and Docker cases can reach private repos Sep 23, 2026
…vate repos

Add Case -> Clone Repo could only reach public repositories in the Docker
deployment. This lets a deployment opt in to the GitHub CLI and the Azure
CLI (+ azure-devops extension) as git credential helpers. Codeman itself
still collects no credentials.

- server.Dockerfile / agent.Dockerfile: CODEMAN_INSTALL_GH /
  CODEMAN_INSTALL_AZ build args (0 or 1, default 0; anything else stops the
  build). Off leaves no apt repository, package, extension, helper script
  or credential entry, so a default build is unchanged. On installs from
  the vendors' apt repositories and configures system gitconfig helpers:
  github.com / gist.github.com -> `gh auth git-credential`, dev.azure.com /
  *.visualstudio.com -> new docker/git-credential-azure-cli (an Entra ID
  token from `az account get-access-token`, or AZURE_DEVOPS_EXT_PAT).
  A helper whose CLI is not signed in prints nothing, so a private clone
  still fails fast.
- The extension lives in AZURE_EXTENSION_DIR outside HOME
  (/opt/codeman-az-extensions, runtime-owned; /opt/az-extensions, gid-0
  group-writable in the agent image).
- Hosts turn them on in docker-compose.override.yml: `build: args:` for the
  server image, `environment:` CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ for the
  agent image. build-agent-image.mjs and the in-app auto-build share one
  env -> ARG table (pinned by the parity test) and pass nothing when unset.
  docker-compose.yaml is untouched; .env.example only gains a comment, so
  the self-updater's environment gate sees no new keys.
- Docker cases seed the gh sign-in (~/.config/gh/hosts.yml, config.yml) and
  the az sign-in files from ~/.azure per file, read-only, like pi/grok.
- The Clone Repo AUTH_REQUIRED message says how to sign the server's git
  in instead of claiming private repositories cannot be cloned.
- Docs: docker/README.md "Private repositories", docker-compose.md,
  docker-cases.md, the Quick-Start / Core-Concepts / Docker-Cases wiki
  pages, security-architecture.md, architecture-invariants.md, changeset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167CiuzLrmjYWxwKp3rMWjw
@opticon454
opticon454 force-pushed the feature/git-host-auth-clis branch from 63e7290 to 5cf5a45 Compare September 23, 2026 00:57
@Ark0N

Ark0N commented Sep 23, 2026

Copy link
Copy Markdown
Owner

Thanks a lot for this, @opticon454. It adds opt-in gh and az CLIs with git credential helpers to the Docker images so Clone Repo and Docker cases can reach private GitHub and Azure DevOps repos, and the Dockerfile work, the validated build args and the parity tests are really careful.

Two things need to change before merge:

  1. The gh/az credential seeding is not behind the opt-in (src/docker-hosts.ts:864 and the .azure entry after it). CRED_STORES is applied to every Docker case with credential mounting on, so any host that already has ~/.config/gh/hosts.yml or a plaintext ~/.azure/msal_token_cache.json now copies them into every new case container, whether or not the switches are set and whether or not the image has the CLIs. Those files are not inert without the CLIs: hosts.yml holds a plaintext token, and the MSAL cache holds refresh tokens good for the whole Azure account, inside a container running a prompt-injectable agent. Please gate each entry on its switch (CODEMAN_AGENT_IMAGE_INSTALL_GH=1 for .config/gh, _AZ=1 for .azure, resolved at call time), add a test that the default environment seeds neither, and update the docs/changeset wording to match.

  2. Multi-user mode: non-admins inherit the server's git sign-in through Clone Repo (src/web/routes/case-routes.ts:493). The clone route is intentionally not admin-gated because it only writes inside the caller's own cases dir, and the local-transport case is gated precisely because it reads through that boundary. With system credential helpers and a signed-in admin, a private https URL reads through it too, since every user shares the one server account. Please run non-admin clones and preflights with the helpers cleared (for example -c credential.helper= on the git argv in src/git-clone.ts) when multi-user mode is on, with a test pinning that argv, and mention the behaviour in docker/README.md and docs/security-architecture.md.

Smaller points:

  • Seeds are create-time mounts, so an existing case container never picks up the gh/az files. That is the right behaviour (hashing them would trip the drift gate for everyone), but docker/README.md and docs/wiki/Docker-Cases.md should tell users to recreate the case container.
  • docker/server.Dockerfile: with both switches off the image still gains ENV AZURE_EXTENSION_DIR, an empty extensions directory and a COPY+rm layer, so "unchanged" in the comments and changeset is slightly overstated. Either say "functionally unchanged" or move the ENV and directory into the az branch.

No need to pin gh/az for now, your reasoning there is fine. Once the two points above are in, this is ready to merge.

…for non-admin clones

Addresses the review on Ark0N#472.

- CRED_STORES: `.config/gh` and `.azure` now carry `enabledByEnv`
  (CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ), and resolveDockerCredentialArtifacts
  skips a store unless that variable is exactly `1`, read at container
  create. A host that merely has ~/.config/gh/hosts.yml or a plaintext MSAL
  cache no longer copies them into every case container. Tests: the default
  environment seeds neither even with the files present, and each store
  follows only its own switch.
- Multi-user mode: a non-admin's Clone Repo clone and preflight run with
  `git -c credential.helper=` (GIT_NO_CREDENTIAL_HELPERS, placed before the
  subcommand), so the server account's helpers are never lent to them.
  Verified against a real private repo that it also clears the URL-scoped
  credential.<url>.helper entries, and that public clones still work.
  Tests: the argv in test/git-clone.test.ts, and the route decision
  (non-admin cleared; admin and single-user kept) in
  test/routes/case-clone-credential-helpers.test.ts.
- Docs: recreate the case container to pick up seeds (docker/README.md,
  Docker-Cases wiki, docker-cases.md); the multi-user behaviour in
  docker/README.md and security-architecture.md; "functionally unchanged"
  instead of "unchanged" for an image built with both switches off
  (server.Dockerfile comment, README, changeset).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167CiuzLrmjYWxwKp3rMWjw
@opticon454

Copy link
Copy Markdown
Contributor Author

Thanks @Ark0N, both are fixed in 02e40f5 (a follow-up commit on top of the squashed one, so the delta is easy to review; happy to squash-merge).

1. gh/az seeding is now behind the opt-in. CredStorePolicy gained enabledByEnv. .config/gh needs CODEMAN_AGENT_IMAGE_INSTALL_GH and .azure needs CODEMAN_AGENT_IMAGE_INSTALL_AZ. resolveDockerCredentialArtifacts(home, env = process.env) skips a store unless its variable is exactly 1, and it's resolved at call time, i.e. at container create. New tests in test/docker-hosts.test.ts:

  • the default environment (and 0 / empty) seeds neither, even with hosts.yml and msal_token_cache.json present on the host;
  • each store follows only its own switch;
  • true / yes don't count.

The docs and changeset now say the seeds are switch-gated, and I dropped the "inert without the CLIs" wording, which was wrong for exactly the reason you gave.

2. Multi-user non-admins no longer inherit the server's git sign-in. A non-admin's clone and preflight run with git -c credential.helper=: GIT_NO_CREDENTIAL_HELPERS in src/git-clone.ts, placed before the subcommand, decided by cloneWithoutCredentialHelpers(req) in case-routes.ts (multi-user and not admin). I checked against a real private repo in the built image, with the helper configured: refs come back normally, and with the helper cleared git fails with could not read Username. So the empty -c also drops the URL-scoped credential.<url>.helper entries, and public repos still clone. Tests:

  • the exact argv, clone and ls-remote, with and without, in test/git-clone.test.ts;
  • the route decision in the new test/routes/case-clone-credential-helpers.test.ts: non-admin cleared for both preflight and clone, admin and single-user kept. Only the two network calls are mocked.

Documented in docker/README.md and in the multi-user section of docs/security-architecture.md. One thing I left alone and said so there: this closes the Clone Repo path only. The server account's SSH keys still apply to an ssh:// URL, which predates this PR, and a non-admin's agent sessions run as the same account anyway, consistent with "not a security boundary between users".

Smaller points

  • Recreate the case container: docker/README.md, docs/wiki/Docker-Cases.md and docs/docker-cases.md now tell users to recreate the case container after turning a switch on, signing in or rebuilding the agent image. docker-cases.md also notes why the seeds are deliberately kept out of the config hash.
  • "Unchanged" wording: I went with "functionally unchanged" and spelled out what remains with both switches off: the AZURE_EXTENSION_DIR variable, its empty directory, and the COPY+rm layer. That's in the server.Dockerfile comment, the README and the changeset.

Verification on 02e40f5: full gate (npm test, the CI config) in a Linux container: 415 files / 7840 tests passed, 12 skipped, 0 failed; tsc --noEmit and lint clean.

@Ark0N
Ark0N merged commit f1dfbcd into Ark0N:master Sep 23, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Sep 23, 2026
#472)

Clone Repo clears the credential helpers for a non-admin, but a non-admin's
Docker case with credential seeding on still receives a copy of the server
account's gh/az sign-in when the agent-image switches are on, the same as
the Claude and Codex credentials. Say so in the multi-user notes so the docs
do not read as a stronger guarantee than they are.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Ark0N

Ark0N commented Sep 23, 2026

Copy link
Copy Markdown
Owner

Merged, thanks @opticon454! This ships in 1.32.1.

Both points from my review came back fixed properly: gh/az seeding only happens behind the agent-image switches, with tests, and a non-admin's clone and preflight run with the credential helpers cleared, pinned in both the argv and the route tests. Having everything opt-in with "switch off leaves nothing behind" as the rule made this easy to take.

One addition at merge: the multi-user notes in docs/security-architecture.md and docker/README.md now say that with the agent-image switches on, a non-admin's Docker case with credential seeding on also receives the server account's gh/az sign-in, the same trust Claude/Codex credentials already get. No code change, just making the docs say it out loud. I also took the changeset down from minor to patch, since this batch goes out as 1.32.1.

@github-actions github-actions Bot mentioned this pull request Sep 23, 2026
@opticon454
opticon454 deleted the feature/git-host-auth-clis branch September 24, 2026 00:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants