From da1f3c22f18d183c878dc80842ae2bf92f749bc5 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:09:50 +0800 Subject: [PATCH] feat(docker): configure static git identity --- CHANGELOG.md | 4 ++ docker/.env.example | 6 +++ docker/README.md | 19 ++++++++++ docker/agent.Dockerfile | 14 +++++++ docker/docker-compose.yaml | 6 +++ docker/server.Dockerfile | 14 +++++++ scripts/lib/cli-catalog.mjs | 23 +++++++++++- src/docker-hosts.ts | 26 ++++++++++++- test/agent-image-build-args-parity.test.ts | 43 ++++++++++++++++++++++ 9 files changed, 153 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd7d1de9..7e55296eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # aicodeman +## 20260925-1305 + +- Added static Git name and email configuration to the server and Docker-case agent image builds. + ## 1.33.0 ### Minor Changes diff --git a/docker/.env.example b/docker/.env.example index 955310080..8e929697f 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -15,6 +15,12 @@ TZ=Australia/Perth # this value rebuilds the image with a matching account. CODEMAN_RUNTIME_USER=codeman +# Required for Git commits made by Codeman and Docker-case agents. These values +# are written to each image's system Git configuration when it is rebuilt, so +# they remain available even when the runtime home directory is a fresh mount. +GIT_USER_NAME= +GIT_USER_EMAIL= + # Required. Persistent Codeman application data, CLI credentials, and session # state are stored here on the host and mounted at the runtime account's home # directory in the container. diff --git a/docker/README.md b/docker/README.md index 87aa2f2de..0358add46 100644 --- a/docker/README.md +++ b/docker/README.md @@ -67,6 +67,25 @@ two volumes are removed, by name within this Compose project; any volume a `docker-compose.override.yml` adds is left alone, and application data and case workspaces are host bind mounts, never touched either way. +## Git commit identity + +Set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` before rebuilding: + +```sh +GIT_USER_NAME='Your Name' +GIT_USER_EMAIL='you@example.com' +``` + +Compose passes the values to the Codeman server build, and to the server process +when it builds Docker-case agent images. Both images write the pair to Git's +system configuration during their build, so commits retain the same identity +after a container or agent image is recreated. Set both values together; an +image build with only one value fails rather than using a partial identity. + +Run `bash docker/Start-Codeman.sh` after changing the server values. Rebuild an +existing agent image with `node scripts/build-agent-image.mjs --no-cache` in the +server container, then recreate any Docker cases that should use it. + ## Private repositories (GitHub and Azure DevOps) The images can include the GitHub CLI (`gh`) and the Azure CLI (`az`, with the `azure-devops` extension), wired into the system Git configuration as credential helpers, so Codeman can clone private repositories. Both are **opt-in and off by default**, and are turned on per host in `docker-compose.override.yml`. diff --git a/docker/agent.Dockerfile b/docker/agent.Dockerfile index f7b460580..78098efbc 100644 --- a/docker/agent.Dockerfile +++ b/docker/agent.Dockerfile @@ -12,6 +12,9 @@ # writable even though the uid is not the baked 1000. FROM node:22-bookworm-slim +ARG GIT_USER_EMAIL= +ARG GIT_USER_NAME= + # Base toolchain. `curl` is needed for the hook callbacks (`curl -sk $CODEMAN_API_URL`), # `procps` for `ps`, `tmux` for the durable in-container session. RUN apt-get update \ @@ -26,6 +29,17 @@ RUN apt-get update \ openssh-client \ && rm -rf /var/lib/apt/lists/* +# Docker cases run with a fresh, container-owned home directory. Configure Git +# at the system level during the build so the identity supplied in docker/.env +# remains stable after an agent image rebuild. Refuse an incomplete identity. +RUN set -eux; \ + if [ -n "${GIT_USER_NAME}" ] || [ -n "${GIT_USER_EMAIL}" ]; then \ + test -n "${GIT_USER_NAME}"; \ + test -n "${GIT_USER_EMAIL}"; \ + git config --system user.name "${GIT_USER_NAME}"; \ + git config --system user.email "${GIT_USER_EMAIL}"; \ + fi + # GitHub CLI and Azure CLI (+ the azure-devops extension) with the same system # git credential helpers as docker/server.Dockerfile, so an agent in a Docker # case can clone and push to private GitHub / Azure DevOps repositories. The diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index d26b2a97e..9b50e8f1c 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -7,6 +7,8 @@ services: dockerfile: docker/server.Dockerfile args: CODEMAN_RUNTIME_USER: ${CODEMAN_RUNTIME_USER} + GIT_USER_EMAIL: ${GIT_USER_EMAIL:-} + GIT_USER_NAME: ${GIT_USER_NAME:-} PGID: ${PGID:-1000} PUID: ${PUID:-1000} image: ${CODEMAN_IMAGE} @@ -32,6 +34,10 @@ services: CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH} CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT} CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH} + # Passed through only so Codeman can use the same identity when it builds + # the Docker-case agent image. + GIT_USER_EMAIL: ${GIT_USER_EMAIL:-} + GIT_USER_NAME: ${GIT_USER_NAME:-} # Extra Host-header allowlist entries for a reverse-proxied deployment # (docker/README.md, "Reverse-proxy host allowlist"). Optional, so it # defaults to empty rather than requiring a line in every .env. diff --git a/docker/server.Dockerfile b/docker/server.Dockerfile index 09b4607a8..2b89bb5f4 100644 --- a/docker/server.Dockerfile +++ b/docker/server.Dockerfile @@ -25,6 +25,8 @@ RUN npm ci \ FROM node:22-bookworm-slim ARG CODEMAN_RUNTIME_USER=codeman +ARG GIT_USER_EMAIL= +ARG GIT_USER_NAME= ARG PUID=1000 ARG PGID=1000 @@ -47,6 +49,18 @@ RUN apt-get update \ tmux \ && rm -rf /var/lib/apt/lists/* +# A runtime home is normally a bind mount, so user-level Git configuration is +# not durable across a fresh deployment. Keep the operator-supplied identity in +# the image's system config instead. Both values are required together to avoid +# producing commits with a misleading partial identity. +RUN set -eux; \ + if [ -n "${GIT_USER_NAME}" ] || [ -n "${GIT_USER_EMAIL}" ]; then \ + test -n "${GIT_USER_NAME}"; \ + test -n "${GIT_USER_EMAIL}"; \ + git config --system user.name "${GIT_USER_NAME}"; \ + git config --system user.email "${GIT_USER_EMAIL}"; \ + fi + # The Docker CLI, taken from the official image rather than Debian's `docker.io`. # That package is the full ENGINE: with --no-install-recommends it still pulls 15 # packages including containerd, runc, dmsetup and iptables, none of which a diff --git a/scripts/lib/cli-catalog.mjs b/scripts/lib/cli-catalog.mjs index 6380b3fe1..23edb1369 100644 --- a/scripts/lib/cli-catalog.mjs +++ b/scripts/lib/cli-catalog.mjs @@ -64,6 +64,12 @@ export const GIT_HOST_CLI_BUILD_ARGS = [ ['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'], ]; +/** Environment variables passed through to the agent image's system Git configuration. */ +export const GIT_IDENTITY_BUILD_ARGS = [ + ['GIT_USER_NAME', 'GIT_USER_NAME'], + ['GIT_USER_EMAIL', 'GIT_USER_EMAIL'], +]; + /** * The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable * contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same @@ -82,9 +88,24 @@ export function gitHostCliBuildArgPairs(env) { return pairs; } +/** The `--build-arg` pairs for Git identity, requiring either both values or neither. */ +export function gitIdentityBuildArgPairs(env) { + const pairs = GIT_IDENTITY_BUILD_ARGS.map(([envName, argName]) => [argName, env[envName] ?? '']); + const configured = pairs.filter(([, value]) => value !== ''); + if (configured.length === 0) return []; + if (configured.length !== pairs.length) { + throw new Error('GIT_USER_NAME and GIT_USER_EMAIL must both be set when configuring Git identity'); + } + return pairs; +} + /** The `--build-arg` pairs the agent image takes. PURE given `env`. */ export function agentImageBuildArgPairs(catalog, env = process.env) { - return [['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')], ...gitHostCliBuildArgPairs(env)]; + return [ + ['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')], + ...gitHostCliBuildArgPairs(env), + ...gitIdentityBuildArgPairs(env), + ]; } /** Read the committed catalogue. IO. */ diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index 5509e89b5..269dd540e 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -615,6 +615,12 @@ export const GIT_HOST_CLI_BUILD_ARGS: ReadonlyArray = ['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'], ]; +/** Environment variables passed through to the agent image's system Git configuration. */ +export const GIT_IDENTITY_BUILD_ARGS: ReadonlyArray = [ + ['GIT_USER_NAME', 'GIT_USER_NAME'], + ['GIT_USER_EMAIL', 'GIT_USER_EMAIL'], +]; + /** * The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable * contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same @@ -633,9 +639,27 @@ export function gitHostCliBuildArgPairs(env: NodeJS.ProcessEnv): Array<[string, return pairs; } +/** + * The `--build-arg` pairs for a configured Git identity. An absent pair leaves + * Git unconfigured, preserving existing deployments; a partial pair is refused. + */ +export function gitIdentityBuildArgPairs(env: NodeJS.ProcessEnv): Array<[string, string]> { + const pairs = GIT_IDENTITY_BUILD_ARGS.map(([envName, argName]) => [argName, env[envName] ?? ''] as [string, string]); + const configured = pairs.filter(([, value]) => value !== ''); + if (configured.length === 0) return []; + if (configured.length !== pairs.length) { + throw new Error('GIT_USER_NAME and GIT_USER_EMAIL must both be set when configuring Git identity'); + } + return pairs; +} + /** The `--build-arg` pairs the agent image takes. PURE given `env`. */ export function agentImageBuildArgPairs(env: NodeJS.ProcessEnv = process.env): Array<[string, string]> { - return [['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')], ...gitHostCliBuildArgPairs(env)]; + return [ + ['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')], + ...gitHostCliBuildArgPairs(env), + ...gitIdentityBuildArgPairs(env), + ]; } // ========== Credential mount resolution (IO) ========== diff --git a/test/agent-image-build-args-parity.test.ts b/test/agent-image-build-args-parity.test.ts index 030235a8f..1f29a4a90 100644 --- a/test/agent-image-build-args-parity.test.ts +++ b/test/agent-image-build-args-parity.test.ts @@ -22,6 +22,8 @@ import { agentImageNpmPackages as mjsPackages, GIT_HOST_CLI_BUILD_ARGS as mjsGitHostArgs, gitHostCliBuildArgPairs as mjsGitHostPairs, + GIT_IDENTITY_BUILD_ARGS as mjsGitIdentityArgs, + gitIdentityBuildArgPairs as mjsGitIdentityPairs, } from '../scripts/lib/cli-catalog.mjs'; import { agentImageBuildArgPairs as tsPairs, @@ -29,6 +31,8 @@ import { agentImageNpmPackages as tsPackages, GIT_HOST_CLI_BUILD_ARGS as tsGitHostArgs, gitHostCliBuildArgPairs as tsGitHostPairs, + GIT_IDENTITY_BUILD_ARGS as tsGitIdentityArgs, + gitIdentityBuildArgPairs as tsGitIdentityPairs, } from '../src/docker-hosts.js'; const CATALOG = JSON.parse(readFileSync(fileURLToPath(new URL('../config/clis.stock.json', import.meta.url)), 'utf-8')); @@ -145,3 +149,42 @@ describe('optional gh / az in the agent image: both producers pass the same swit } }); }); + +describe('Git identity in the agent image: both producers pass the same settings', () => { + it('maps the Git environment variables to matching Dockerfile ARGs', () => { + expect(tsGitIdentityArgs).toEqual(mjsGitIdentityArgs); + expect(tsGitIdentityArgs).toEqual([ + ['GIT_USER_NAME', 'GIT_USER_NAME'], + ['GIT_USER_EMAIL', 'GIT_USER_EMAIL'], + ]); + }); + + it('passes a complete identity and omits an absent identity', () => { + const identity = { GIT_USER_NAME: 'Ada Lovelace', GIT_USER_EMAIL: 'ada@example.com' }; + const expected: Array<[string, string]> = [ + ['GIT_USER_NAME', 'Ada Lovelace'], + ['GIT_USER_EMAIL', 'ada@example.com'], + ]; + expect(tsGitIdentityPairs(identity)).toEqual(expected); + expect(mjsGitIdentityPairs(identity)).toEqual(expected); + expect(tsGitIdentityPairs({})).toEqual([]); + expect(mjsGitIdentityPairs({})).toEqual([]); + }); + + it('refuses a partial identity in both build paths', () => { + for (const identity of [{ GIT_USER_NAME: 'Ada Lovelace' }, { GIT_USER_EMAIL: 'ada@example.com' }]) { + expect(() => tsGitIdentityPairs(identity)).toThrow(/GIT_USER_NAME and GIT_USER_EMAIL/); + expect(() => mjsGitIdentityPairs(identity)).toThrow(/GIT_USER_NAME and GIT_USER_EMAIL/); + } + }); + + it('both Dockerfiles configure system Git identity from the build arguments', () => { + 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 GIT_USER_NAME=$/m); + expect(dockerfile, file).toMatch(/^ARG GIT_USER_EMAIL=$/m); + expect(dockerfile, file).toContain('git config --system user.name "${GIT_USER_NAME}"'); + expect(dockerfile, file).toContain('git config --system user.email "${GIT_USER_EMAIL}"'); + } + }); +});