feat(docker): opt-in gh + az CLIs with git credential helpers for private 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
This commit is contained in:
Devvyn
2026-09-23 08:57:09 +08:00
co-authored by Claude Opus 5.5
parent 9466acfc1a
commit 5cf5a45438
18 changed files with 501 additions and 14 deletions
+27
View File
@@ -351,6 +351,33 @@ describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencod
expect(mounts.filter((m) => m.readonly && m.dst.includes('cred-seeds')).length).toBeGreaterThanOrEqual(3);
});
it('gh + az: seed only the sign-in files, never logs/extensions/caches', () => {
mkdirSync(join(home, '.config', 'gh'), { recursive: true });
writeFileSync(join(home, '.config', 'gh', 'hosts.yml'), '');
writeFileSync(join(home, '.config', 'gh', 'config.yml'), '');
mkdirSync(join(home, '.azure', 'logs'), { recursive: true });
mkdirSync(join(home, '.azure', 'cliextensions'), { recursive: true });
writeFileSync(join(home, '.azure', 'azureProfile.json'), '{}');
writeFileSync(join(home, '.azure', 'msal_token_cache.json'), '{}');
writeFileSync(join(home, '.azure', 'config'), '');
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
const dests = seedCopies.map((s) => s.to);
expect(dests).toContain('/home/agent/.config/gh/hosts.yml');
expect(dests).toContain('/home/agent/.config/gh/config.yml');
expect(dests).toContain('/home/agent/.azure/azureProfile.json');
expect(dests).toContain('/home/agent/.azure/msal_token_cache.json');
expect(dests).toContain('/home/agent/.azure/config');
// Absent files are skipped, and nothing outside the sign-in set is seeded.
expect(dests).not.toContain('/home/agent/.azure/service_principal_entries.json');
expect(dests.some((d) => d.includes('logs') || d.includes('cliextensions'))).toBe(false);
expect(seedCopies.filter((s) => /\.azure|\.config\/gh/.test(s.to)).every((s) => !s.recursive)).toBe(true);
// Every host credential file rides a READ-ONLY mount, so the container never writes back.
const credMounts = mounts.filter((m) => /\.azure|\.config[\\/]gh/.test(m.src));
expect(credMounts.length).toBe(5);
expect(credMounts.every((m) => m.readonly)).toBe(true);
});
it('gates every artifact on existsSync (absent stores contribute nothing)', () => {
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
expect(mounts).toEqual([]);