Merge pull request #492 from opticon454/feature/static-git-identity

feat(docker): configure static git identity
This commit is contained in:
Codeman maintainer
2026-09-28 16:20:56 +02:00
10 changed files with 167 additions and 3 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"aicodeman": minor
---
Docker deployments can configure a static Git commit identity for server and Docker-case agent images.
+6
View File
@@ -15,6 +15,12 @@ TZ=Australia/Perth
# this value rebuilds the image with a matching account. # this value rebuilds the image with a matching account.
CODEMAN_RUNTIME_USER=codeman 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 # Required. Persistent Codeman application data, CLI credentials, and session
# state are stored here on the host and mounted at the runtime account's home # state are stored here on the host and mounted at the runtime account's home
# directory in the container. # directory in the container.
+21
View File
@@ -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 `docker-compose.override.yml` adds is left alone, and application data and
case workspaces are host bind mounts, never touched either way. 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) ## 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`. 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`.
+15
View File
@@ -254,6 +254,21 @@ RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
&& chgrp -R 0 /home/agent \ && chgrp -R 0 /home/agent \
&& chmod -R g=u /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 USER agent
WORKDIR /home/agent WORKDIR /home/agent
+6
View File
@@ -7,6 +7,8 @@ services:
dockerfile: docker/server.Dockerfile dockerfile: docker/server.Dockerfile
args: args:
CODEMAN_RUNTIME_USER: ${CODEMAN_RUNTIME_USER} CODEMAN_RUNTIME_USER: ${CODEMAN_RUNTIME_USER}
GIT_USER_EMAIL: ${GIT_USER_EMAIL:-}
GIT_USER_NAME: ${GIT_USER_NAME:-}
PGID: ${PGID:-1000} PGID: ${PGID:-1000}
PUID: ${PUID:-1000} PUID: ${PUID:-1000}
image: ${CODEMAN_IMAGE} image: ${CODEMAN_IMAGE}
@@ -32,6 +34,10 @@ services:
CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH} CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH}
CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT} CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT}
CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH} 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 # Extra Host-header allowlist entries for a reverse-proxied deployment
# (docker/README.md, "Reverse-proxy host allowlist"). Optional, so it # (docker/README.md, "Reverse-proxy host allowlist"). Optional, so it
# defaults to empty rather than requiring a line in every .env. # defaults to empty rather than requiring a line in every .env.
+15
View File
@@ -302,6 +302,21 @@ EXPOSE 3000
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod 0755 /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"] ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["node", "dist/index.js", "web"] CMD ["node", "dist/index.js", "web"]
+2
View File
@@ -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. 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" ## 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/<name>`, 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. 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/<name>`, 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.
+22 -1
View File
@@ -64,6 +64,12 @@ export const GIT_HOST_CLI_BUILD_ARGS = [
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'], ['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 * 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 * 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; 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`. */ /** The `--build-arg` pairs the agent image takes. PURE given `env`. */
export function agentImageBuildArgPairs(catalog, env = process.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. */ /** Read the committed catalogue. IO. */
+26 -2
View File
@@ -615,6 +615,12 @@ export const GIT_HOST_CLI_BUILD_ARGS: ReadonlyArray<readonly [string, string]> =
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'], ['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<readonly [string, string]> = [
['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 * 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 * 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; 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`. */ /** The `--build-arg` pairs the agent image takes. PURE given `env`. */
export function agentImageBuildArgPairs(env: NodeJS.ProcessEnv = process.env): Array<[string, string]> { 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) ========== // ========== Credential mount resolution (IO) ==========
@@ -1204,7 +1228,7 @@ function buildAgentImage(
try { try {
buildArgPairs = agentImageBuildArgPairs(); buildArgPairs = agentImageBuildArgPairs();
} catch (err) { } 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) }); return Promise.resolve({ ok: false, built: false, alreadyPresent: false, error: String((err as Error).message) });
} }
const argv = dockerEngineArgv(docker); const argv = dockerEngineArgv(docker);
@@ -22,6 +22,8 @@ import {
agentImageNpmPackages as mjsPackages, agentImageNpmPackages as mjsPackages,
GIT_HOST_CLI_BUILD_ARGS as mjsGitHostArgs, GIT_HOST_CLI_BUILD_ARGS as mjsGitHostArgs,
gitHostCliBuildArgPairs as mjsGitHostPairs, gitHostCliBuildArgPairs as mjsGitHostPairs,
GIT_IDENTITY_BUILD_ARGS as mjsGitIdentityArgs,
gitIdentityBuildArgPairs as mjsGitIdentityPairs,
} from '../scripts/lib/cli-catalog.mjs'; } from '../scripts/lib/cli-catalog.mjs';
import { import {
agentImageBuildArgPairs as tsPairs, agentImageBuildArgPairs as tsPairs,
@@ -29,6 +31,8 @@ import {
agentImageNpmPackages as tsPackages, agentImageNpmPackages as tsPackages,
GIT_HOST_CLI_BUILD_ARGS as tsGitHostArgs, GIT_HOST_CLI_BUILD_ARGS as tsGitHostArgs,
gitHostCliBuildArgPairs as tsGitHostPairs, gitHostCliBuildArgPairs as tsGitHostPairs,
GIT_IDENTITY_BUILD_ARGS as tsGitIdentityArgs,
gitIdentityBuildArgPairs as tsGitIdentityPairs,
} from '../src/docker-hosts.js'; } from '../src/docker-hosts.js';
const CATALOG = JSON.parse(readFileSync(fileURLToPath(new URL('../config/clis.stock.json', import.meta.url)), 'utf-8')); 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}"');
}
});
});