diff --git a/.changeset/static-git-identity.md b/.changeset/static-git-identity.md new file mode 100644 index 00000000..345e124a --- /dev/null +++ b/.changeset/static-git-identity.md @@ -0,0 +1,5 @@ +--- +"aicodeman": minor +--- + +Docker deployments can configure a static Git commit identity for server and Docker-case agent images. diff --git a/docker/.env.example b/docker/.env.example index 95531008..fbdb1229 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 +# Optional Git identity for commits made by Codeman and Docker-case agents. These values +# are written to each image's system Git configuration when it is rebuilt, so +# deployments can configure a consistent default. Set both values together. +# 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 87aa2f2d..5328fb65 100644 --- a/docker/README.md +++ b/docker/README.md @@ -67,6 +67,27 @@ 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. An +identity already present in `CODEMAN_APPDATA_PATH`'s `~/.gitconfig` overrides +the server image's system-level default. + +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 fa7dfdd9..afc9c6e1 100644 --- a/docker/agent.Dockerfile +++ b/docker/agent.Dockerfile @@ -254,6 +254,21 @@ RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \ && chgrp -R 0 /home/agent \ && chmod -R g=u /home/agent +# Docker cases have a fresh, container-owned home directory. Declare the +# optional identity here so changing it invalidates only this final layer, then +# configure Git's system defaults. A user-level config still takes precedence. +ARG GIT_USER_EMAIL= +ARG GIT_USER_NAME= +RUN set -eux; \ + if [ -n "${GIT_USER_NAME}" ] || [ -n "${GIT_USER_EMAIL}" ]; then \ + if [ -z "${GIT_USER_NAME}" ] || [ -z "${GIT_USER_EMAIL}" ]; then \ + echo 'Git user name and email must both be set when configuring Git identity' >&2; \ + exit 1; \ + fi; \ + git config --system user.name "${GIT_USER_NAME}"; \ + git config --system user.email "${GIT_USER_EMAIL}"; \ + fi + USER agent WORKDIR /home/agent diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index d26b2a97..db6e17c3 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. + CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL: ${GIT_USER_EMAIL:-} + CODEMAN_AGENT_IMAGE_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 305fc392..4652748d 100644 --- a/docker/server.Dockerfile +++ b/docker/server.Dockerfile @@ -302,6 +302,21 @@ EXPOSE 3000 COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod 0755 /usr/local/bin/entrypoint.sh +# Declare the optional identity immediately before configuring it so a change +# invalidates only this final layer. This is declarative setup: a persisted +# ~/.gitconfig in CODEMAN_APPDATA_PATH still overrides the system-level values. +ARG GIT_USER_EMAIL= +ARG GIT_USER_NAME= +RUN set -eux; \ + if [ -n "${GIT_USER_NAME}" ] || [ -n "${GIT_USER_EMAIL}" ]; then \ + if [ -z "${GIT_USER_NAME}" ] || [ -z "${GIT_USER_EMAIL}" ]; then \ + echo 'Git user name and email must both be set when configuring Git identity' >&2; \ + exit 1; \ + fi; \ + git config --system user.name "${GIT_USER_NAME}"; \ + git config --system user.email "${GIT_USER_EMAIL}"; \ + fi + ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] CMD ["node", "dist/index.js", "web"] diff --git a/docs/docker-cases.md b/docs/docker-cases.md index f8a1459a..4b8bd7bc 100644 --- a/docs/docker-cases.md +++ b/docs/docker-cases.md @@ -83,6 +83,8 @@ Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.jso The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated. +Set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together to configure the agent image's Git identity. Rebuild an existing `codeman/agent:base` with `node scripts/build-agent-image.mjs --no-cache`, then recreate Docker-case containers so they use the rebuilt image. + ## Quickest path: one-click "Run in Docker" On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in. diff --git a/scripts/lib/cli-catalog.mjs b/scripts/lib/cli-catalog.mjs index 6380b3fe..fd9c53f6 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 = [ + ['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'], + ['CODEMAN_AGENT_IMAGE_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 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 5509e89b..3b6f7744 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 = [ + ['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'], + ['CODEMAN_AGENT_IMAGE_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 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) ========== @@ -1204,7 +1228,7 @@ function buildAgentImage( try { buildArgPairs = agentImageBuildArgPairs(); } catch (err) { - // A malformed CODEMAN_AGENT_IMAGE_INSTALL_* value: report it like any other build failure. + // A malformed CODEMAN_AGENT_IMAGE_* value: report it like any other build failure. return Promise.resolve({ ok: false, built: false, alreadyPresent: false, error: String((err as Error).message) }); } const argv = dockerEngineArgv(docker); diff --git a/test/agent-image-build-args-parity.test.ts b/test/agent-image-build-args-parity.test.ts index 030235a8..3ca358d0 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,48 @@ 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([ + ['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'], + ['CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL', 'GIT_USER_EMAIL'], + ]); + }); + + it('passes a complete identity and omits an absent identity', () => { + const identity = { + CODEMAN_AGENT_IMAGE_GIT_USER_NAME: 'Ada Lovelace', + CODEMAN_AGENT_IMAGE_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 [ + { CODEMAN_AGENT_IMAGE_GIT_USER_NAME: 'Ada Lovelace' }, + { CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL: 'ada@example.com' }, + ]) { + expect(() => tsGitIdentityPairs(identity)).toThrow(/Git user name and email/); + expect(() => mjsGitIdentityPairs(identity)).toThrow(/Git user name and 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}"'); + } + }); +});