feat(docker): derive the agent image's npm CLI list from the catalogue

docker/agent.Dockerfile hardcoded the four npm-published CLIs it installs, one
of the several lists that had to be kept in step with the registry by hand.

It now takes them as `ARG CLI_NPM_PACKAGES`, supplied by
scripts/build-agent-image.mjs from config/clis.stock.json, with the default set
to today's list so a bare `docker build` still produces the same image. The arg
is expanded unquoted because word splitting is what turns the list into several
arguments, which is exactly why every token is validated against
^[@A-Za-z0-9][@A-Za-z0-9/._-]*$ on the producing side; a package name carrying a
space or a metacharacter is refused rather than reaching the RUN line. Verified
by building the layer: four packages in, four arguments out, and the default
still applies with no arg.

The list is filtered on each entry's `enabled` flag — the field whose absence
was the maintainer's §3 finding, where a CLI shipping disabled still got baked
into every image. No stock entry is disabled today, so that assertion would pass
vacuously; a unit test feeds the pure helper a fabricated disabled entry so the
fix is covered now rather than the first time someone ships one.

⚠️ It reads the STOCK catalogue, never the merged registry. A user's
~/.codeman/clis.json must not change what is inside an image tagged
codeman/agent:base, or two machines holding that tag hold different images.

Four CLIs keep hand-written layers because the registry cannot describe what
makes them special: pi's --ignore-scripts, deepseek's pnpm companion and dsh-tui
profile, and the three standalone installers. Rather than extend the schema for
a Docker-only benefit, the coverage test requires each to carry a written reason
AND still be present, so an exclusion cannot quietly become an omission.

There are two producers of this command line and there have to be — the .mjs
cannot import TypeScript, and src/docker-hosts.ts builds the same argv for the
in-app auto-build — so a parity test pins them together, package list, arg pairs
and rendered argv. Their order is pinned too: a different order is a different
RUN string and so a needless cache miss between the two build paths.

docker/server.Dockerfile is deliberately NOT edited (PRs #373 and #377 both
modify it); its narrower list is asserted as a declared omission list instead, so
the divergence is reviewable without touching the file.

Also fixes the in-app hint at index.html, which the new coverage test caught
still omitting omp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015EMxQreQUZX5ZyybxAGh12
This commit is contained in:
Devvyn
2026-09-13 17:43:13 +08:00
co-authored by Claude Opus 5
parent 1ca35095e7
commit 7af4dbc0f8
7 changed files with 367 additions and 11 deletions
+48 -3
View File
@@ -25,6 +25,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { enabledCliIds, getCli } from './config/cli-registry/registry.js';
import { STOCK_CLIS } from './config/cli-registry/stock.js';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import { createHash } from 'node:crypto';
@@ -488,8 +489,52 @@ export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] {
* scripts/build-agent-image.mjs): `build -f <dockerfile> -t <image> [--no-cache]
* <contextDir>`. Kept pure + unit-testable; the caller prepends the engine binary.
*/
export function agentImageBuildArgs(dockerfile: string, image: string, contextDir: string, noCache = false): string[] {
return ['build', '-f', dockerfile, '-t', image, ...(noCache ? ['--no-cache'] : []), contextDir];
export function agentImageBuildArgs(
dockerfile: string,
image: string,
contextDir: string,
noCache = false,
buildArgs: Array<[string, string]> = []
): string[] {
return [
'build',
'-f',
dockerfile,
'-t',
image,
...(noCache ? ['--no-cache'] : []),
...buildArgs.flatMap(([name, value]) => ['--build-arg', `${name}=${value}`]),
contextDir,
];
}
/**
* npm packages the agent image installs in its shared layer, from the STOCK catalogue.
*
* ⚠️ Stock, deliberately, NOT the merged registry. A user's `~/.codeman/clis.json` must not
* change what lands inside an image tagged `codeman/agent:base`, or two machines holding that
* same tag hold different images and every cache-hit decision downstream is a lie.
*
* ⚠️ This mirrors `agentImageNpmPackages()` in `scripts/lib/cli-catalog.mjs`, which the CLI
* build path uses because a `.mjs` cannot import TypeScript. Two producers of one command
* line drift; `test/agent-image-build-args-parity.test.ts` is what stops them.
*/
export function agentImageNpmPackages(): string[] {
return STOCK_CLIS.filter((entry) => entry.enabled && !AGENT_IMAGE_SPECIAL_CASE_IDS.has(entry.id as string))
.map((entry) => entry.discovery.install.npmPackage)
.filter((pkg): pkg is string => Boolean(pkg));
}
/**
* CLIs the agent image installs in their OWN Dockerfile layer rather than the shared npm one.
* The registry cannot express what makes each special, so the layers stay hand-written and
* `test/docker-agent-image-coverage.test.ts` requires each to carry a reason and still exist.
*/
const AGENT_IMAGE_SPECIAL_CASE_IDS = new Set(['pi', 'deepseek']);
/** The `--build-arg` pairs the agent image takes. */
export function agentImageBuildArgPairs(): Array<[string, string]> {
return [['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')]];
}
// ========== Credential mount resolution (IO) ==========
@@ -1020,7 +1065,7 @@ function buildAgentImage(
const argv = dockerEngineArgv(docker);
const args = [
...argv.slice(1),
...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache),
...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache, agentImageBuildArgPairs()),
];
return new Promise<EnsureImageResult>((resolve) => {
// async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop.
+1 -1
View File
@@ -2905,7 +2905,7 @@
<div class="form-row docker-create-only">
<label>Image</label>
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi/grok/dsh + tmux.</span>
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/opencode/codex/gemini/agy/pi/grok/dsh/omp + tmux.</span>
</div>
<div class="form-row docker-create-only">
<label>Network</label>