Files
Codeman/scripts/lib/cli-catalog.mjs
T
DevvynandClaude Opus 5.5 5cf5a45438 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
2026-09-23 08:57:09 +08:00

94 lines
4.5 KiB
JavaScript

/**
* @fileoverview Reads the generated CLI catalogue for the Docker build.
*
* `scripts/build-agent-image.mjs` is a `.mjs` and cannot import the TypeScript registry, so it
* reads `config/clis.stock.json` (generated by `scripts/generate-cli-catalog.mts`) instead.
* The pure half lives here so `src/docker-hosts.ts`'s programmatic mirror of the same build
* command can be pinned against it by a test — those two produce the docker argv independently
* and must not drift.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const CATALOG_PATH = fileURLToPath(new URL('../../config/clis.stock.json', import.meta.url));
/**
* npm package names the AGENT image installs in its shared `npm install -g` layer.
*
* PURE: takes the parsed catalogue, returns a sorted-by-registry-order list.
*
* ⚠️ Filters on `enabled`. That is the field the earlier attempt's export omitted, which is
* how a CLI that ships disabled still had its package baked into every image.
*
* ⚠️ An entry carrying `discovery.install.agentImageLayer` is excluded here and installed by
* its own hand-written Dockerfile layer instead, because the registry cannot express what
* makes it special — a flag, a companion package, or not being on npm at all. This used to be
* an id-keyed table duplicated between this file and `src/docker-hosts.ts` (exactly the shape
* `test/cli-registry-no-id-branching.test.ts` exists to forbid inside `src/`, which is why it
* was a blind spot rather than a pass — that test scans `src/` only). It is data now: both
* producers filter on the SAME field from the SAME catalogue entry, `reason` is required by
* `schema.ts`, and `test/docker-agent-image-coverage.test.ts` requires every one of them to
* still be present in the Dockerfile, so an exclusion cannot quietly become an omission.
*/
/** Tokens allowed in an npm package name reaching a Dockerfile build arg unquoted. */
const SAFE_PACKAGE = /^[@A-Za-z0-9][@A-Za-z0-9/._-]*$/;
export function agentImageNpmPackages(catalog) {
const packages = [];
for (const entry of catalog) {
if (!entry.enabled) continue;
if (entry.discovery?.install?.agentImageLayer) continue;
const pkg = entry.discovery?.install?.npmPackage;
if (!pkg) continue; // antigravity/grok/omp ship standalone installers, not npm
if (!SAFE_PACKAGE.test(pkg)) {
// The value is interpolated into a Dockerfile ARG that is expanded UNQUOTED (word
// splitting is how the list becomes several arguments), so a token with whitespace or
// shell metacharacters would change what the RUN line means.
// ⚠️ This exact regex is duplicated in `agentImageNpmPackages()` in
// `src/docker-hosts.ts` (that file cannot import this one — it is the TypeScript side of
// the same two-producers split this whole module exists for). Keep both literal patterns
// identical; `test/agent-image-build-args-parity.test.ts` pins that they are.
throw new Error(`Refusing unsafe npm package name for "${entry.id}": ${JSON.stringify(pkg)}`);
}
packages.push(pkg);
}
return packages;
}
/**
* Environment variable → agent.Dockerfile ARG for the optional git-host CLIs (gh, az).
* ⚠️ Mirrored by `GIT_HOST_CLI_BUILD_ARGS` in `src/docker-hosts.ts`; the parity test pins them.
*/
export const GIT_HOST_CLI_BUILD_ARGS = [
['CODEMAN_AGENT_IMAGE_INSTALL_GH', 'CODEMAN_INSTALL_GH'],
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
];
/**
* 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
* as before these existed; anything other than 0/1 is refused rather than guessed at.
*/
export function gitHostCliBuildArgPairs(env) {
const pairs = [];
for (const [envName, argName] of GIT_HOST_CLI_BUILD_ARGS) {
const value = env[envName];
if (value === undefined || value === '') continue;
if (value !== '0' && value !== '1') {
throw new Error(`${envName} must be 0 or 1, got ${JSON.stringify(value)}`);
}
pairs.push([argName, value]);
}
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)];
}
/** Read the committed catalogue. IO. */
export function readCatalog(path = CATALOG_PATH) {
return JSON.parse(readFileSync(path, 'utf-8'));
}