mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
co-authored by
Claude Opus 5.5
parent
9466acfc1a
commit
5cf5a45438
@@ -20,11 +20,15 @@ import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
agentImageBuildArgPairs as mjsPairs,
|
||||
agentImageNpmPackages as mjsPackages,
|
||||
GIT_HOST_CLI_BUILD_ARGS as mjsGitHostArgs,
|
||||
gitHostCliBuildArgPairs as mjsGitHostPairs,
|
||||
} from '../scripts/lib/cli-catalog.mjs';
|
||||
import {
|
||||
agentImageBuildArgPairs as tsPairs,
|
||||
agentImageBuildArgs,
|
||||
agentImageNpmPackages as tsPackages,
|
||||
GIT_HOST_CLI_BUILD_ARGS as tsGitHostArgs,
|
||||
gitHostCliBuildArgPairs as tsGitHostPairs,
|
||||
} from '../src/docker-hosts.js';
|
||||
|
||||
const CATALOG = JSON.parse(readFileSync(fileURLToPath(new URL('../config/clis.stock.json', import.meta.url)), 'utf-8'));
|
||||
@@ -96,3 +100,48 @@ describe('agent-image build args: the .mjs and the TS mirror agree', () => {
|
||||
expect(extract(tsSource, 'docker-hosts.ts')).toBe(extract(mjsSource, 'cli-catalog.mjs'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('optional gh / az in the agent image: both producers pass the same switches', () => {
|
||||
const ENV_GH = 'CODEMAN_AGENT_IMAGE_INSTALL_GH';
|
||||
const ENV_AZ = 'CODEMAN_AGENT_IMAGE_INSTALL_AZ';
|
||||
|
||||
it('map the same environment variables to the same Dockerfile ARGs', () => {
|
||||
expect(tsGitHostArgs).toEqual(mjsGitHostArgs);
|
||||
expect(tsGitHostArgs.map(([, arg]) => arg)).toEqual(['CODEMAN_INSTALL_GH', 'CODEMAN_INSTALL_AZ']);
|
||||
});
|
||||
|
||||
it('agree for every combination, and an unset or empty variable adds nothing', () => {
|
||||
for (const gh of [undefined, '', '0', '1']) {
|
||||
for (const az of [undefined, '', '0', '1']) {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
if (gh !== undefined) env[ENV_GH] = gh;
|
||||
if (az !== undefined) env[ENV_AZ] = az;
|
||||
const expected: Array<[string, string]> = [];
|
||||
if (gh) expected.push(['CODEMAN_INSTALL_GH', gh]);
|
||||
if (az) expected.push(['CODEMAN_INSTALL_AZ', az]);
|
||||
expect(tsGitHostPairs(env)).toEqual(expected);
|
||||
expect(mjsGitHostPairs(env)).toEqual(expected);
|
||||
expect(tsPairs(env)).toEqual(mjsPairs(CATALOG, env));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the default argv unchanged when neither variable is set', () => {
|
||||
expect(tsPairs({})).toEqual([['CLI_NPM_PACKAGES', tsPackages().join(' ')]]);
|
||||
});
|
||||
|
||||
it('refuses anything but 0 or 1 on both sides, naming the variable', () => {
|
||||
for (const bad of ['yes', 'true', '2', ' 1', '0 && echo']) {
|
||||
expect(() => tsGitHostPairs({ [ENV_AZ]: bad })).toThrow(new RegExp(ENV_AZ));
|
||||
expect(() => mjsGitHostPairs({ [ENV_AZ]: bad })).toThrow(new RegExp(ENV_AZ));
|
||||
}
|
||||
});
|
||||
|
||||
it('both Dockerfiles declare the switches, defaulting to OFF (opt-in)', () => {
|
||||
for (const file of ['../docker/agent.Dockerfile', '../docker/server.Dockerfile']) {
|
||||
const dockerfile = readFileSync(fileURLToPath(new URL(file, import.meta.url)), 'utf-8');
|
||||
expect(dockerfile, file).toMatch(/^ARG CODEMAN_INSTALL_GH=0$/m);
|
||||
expect(dockerfile, file).toMatch(/^ARG CODEMAN_INSTALL_AZ=0$/m);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
Reference in New Issue
Block a user