mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-21 18:30:15 +08:00
feat(container): data-drive global CLI installs from cli-tools.json
The agent's global Node CLIs (claude-code, agent-browser, vercel) were each
a hardcoded ARG + RUN layer in the Dockerfile, so adding or bumping one meant
editing the Dockerfile — a code reach-in every tool-installing skill had to make.
Move the tool list into container/cli-tools.json. A skill now adds a CLI by
appending a {name, version} entry (a json-merge) — the safest change shape:
deterministic, idempotent, removable. install-cli-tools.sh parses the manifest
with node (no new jq dep), writes the per-tool only-built-dependencies opt-ins,
and runs one pinned `pnpm install -g`, so the pnpm supply-chain path is unchanged.
Behavior is byte-for-byte: same opt-ins, same pinned installs. agent-browser is
now pinned (0.27.1, what `latest` last resolved to) instead of floating.
container/cli-tools.test.ts guards the seam: red if a baseline tool is dropped,
a version unpins, or the Dockerfile wiring / pnpm path is removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+11
-15
@@ -16,12 +16,11 @@ FROM node:22-slim
|
||||
# CJK fonts add ~200MB. Opt in only if you render Chinese/Japanese/Korean text.
|
||||
ARG INSTALL_CJK_FONTS=false
|
||||
|
||||
# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs
|
||||
# mean every rebuild silently picks up the latest and can break in lockstep
|
||||
# across all users.
|
||||
ARG CLAUDE_CODE_VERSION=2.1.170
|
||||
ARG AGENT_BROWSER_VERSION=latest
|
||||
ARG VERCEL_VERSION=52.2.1
|
||||
# Pin versions for reproducibility. Bump deliberately — unpinned installs mean
|
||||
# every rebuild silently picks up the latest and can break in lockstep across
|
||||
# all users. The global Node CLIs (claude-code, agent-browser, vercel) are
|
||||
# pinned in cli-tools.json so a skill can add one with a json-merge; Bun (the
|
||||
# runtime) is pinned here because it installs from a different source.
|
||||
ARG BUN_VERSION=1.3.12
|
||||
|
||||
# ---- System dependencies -----------------------------------------------------
|
||||
@@ -99,16 +98,13 @@ ENV PATH="$PNPM_HOME:$PATH"
|
||||
ARG PNPM_VERSION=10.33.0
|
||||
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||
|
||||
# Global Node CLIs the agent invokes at runtime live in cli-tools.json so a
|
||||
# skill can add one with a json-merge instead of editing this Dockerfile.
|
||||
# install-cli-tools.sh installs each via pnpm (pinned), writing the per-tool
|
||||
# only-built-dependencies opt-ins it reads from the manifest.
|
||||
COPY cli-tools.json install-cli-tools.sh /tmp/
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \
|
||||
echo "only-built-dependencies[]=@anthropic-ai/claude-code" >> /root/.npmrc && \
|
||||
pnpm install -g "vercel@${VERCEL_VERSION}"
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "agent-browser@${AGENT_BROWSER_VERSION}"
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
|
||||
sh /tmp/install-cli-tools.sh /tmp/cli-tools.json
|
||||
|
||||
# ---- ncl CLI wrapper ----------------------------------------------------------
|
||||
# Actual script lives in the mounted source at /app/src/cli/ncl.ts.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{ "name": "vercel", "version": "52.2.1" },
|
||||
{ "name": "agent-browser", "version": "0.27.1", "onlyBuilt": true },
|
||||
{ "name": "@anthropic-ai/claude-code", "version": "2.1.170", "onlyBuilt": true }
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
// Guards the cli-tools.json seam: the global CLIs the agent invokes at runtime
|
||||
// are installed from the manifest (a skill adds one with a json-merge), not
|
||||
// hand-edited into the Dockerfile. These go red on a bad merge that drops a
|
||||
// baseline tool, or on dewiring the Dockerfile / switching the installer off
|
||||
// the pnpm supply-chain path.
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(join(here, 'cli-tools.json'), 'utf8')) as Array<{
|
||||
name: string;
|
||||
version: string;
|
||||
onlyBuilt?: boolean;
|
||||
}>;
|
||||
const dockerfile = readFileSync(join(here, 'Dockerfile'), 'utf8');
|
||||
const installer = readFileSync(join(here, 'install-cli-tools.sh'), 'utf8');
|
||||
|
||||
describe('cli-tools manifest', () => {
|
||||
it('is a non-empty array of { name, version }', () => {
|
||||
expect(Array.isArray(manifest)).toBe(true);
|
||||
expect(manifest.length).toBeGreaterThan(0);
|
||||
for (const tool of manifest) {
|
||||
expect(typeof tool.name).toBe('string');
|
||||
expect(tool.name.length).toBeGreaterThan(0);
|
||||
expect(typeof tool.version).toBe('string');
|
||||
expect(tool.version.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('has unique tool names (json-merge is keyed on name)', () => {
|
||||
const names = manifest.map((t) => t.name);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
it('pins every version to an exact semver (no latest, no ranges — supply-chain policy)', () => {
|
||||
for (const tool of manifest) {
|
||||
expect(tool.version, `${tool.name} must be an exact semver, not "${tool.version}"`).toMatch(
|
||||
/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the baseline CLIs the agent depends on', () => {
|
||||
const names = manifest.map((t) => t.name);
|
||||
for (const required of ['vercel', 'agent-browser', '@anthropic-ai/claude-code']) {
|
||||
expect(names).toContain(required);
|
||||
}
|
||||
});
|
||||
|
||||
it('is wired into the Dockerfile build (COPY manifest + run installer)', () => {
|
||||
expect(dockerfile).toMatch(/COPY cli-tools\.json install-cli-tools\.sh/);
|
||||
expect(dockerfile).toMatch(/install-cli-tools\.sh \/tmp\/cli-tools\.json/);
|
||||
});
|
||||
|
||||
it('installs via pnpm and writes only-built opt-ins (preserves the supply-chain path)', () => {
|
||||
expect(installer).toMatch(/pnpm install -g/);
|
||||
expect(installer).toMatch(/only-built-dependencies\[\]=/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
# Install the global Node CLIs the agent invokes at runtime, from cli-tools.json.
|
||||
#
|
||||
# A skill adds a tool by appending a { "name", "version" } entry to that
|
||||
# manifest (a json-merge) instead of editing the Dockerfile — the reach-in
|
||||
# becomes the safest change shape, deterministic and removable.
|
||||
#
|
||||
# Every tool is installed via `pnpm install -g`, pinned to an exact version, so
|
||||
# the pnpm supply-chain policy still applies. Tools with a native postinstall
|
||||
# set "onlyBuilt": true to opt in to running build scripts (pnpm skips them by
|
||||
# default). Run as root before `USER node`, so /root/.npmrc is the right home.
|
||||
set -eu
|
||||
|
||||
MANIFEST="${1:-/tmp/cli-tools.json}"
|
||||
|
||||
# Write the per-tool only-built-dependencies opt-ins pnpm reads at install time.
|
||||
node -e '
|
||||
const tools = require(process.argv[1]);
|
||||
const optIns = tools.filter((t) => t.onlyBuilt).map((t) => "only-built-dependencies[]=" + t.name);
|
||||
require("fs").writeFileSync("/root/.npmrc", optIns.join("\n") + (optIns.length ? "\n" : ""));
|
||||
' "$MANIFEST"
|
||||
|
||||
# Install every tool, pinned. name@version specs never contain spaces, so the
|
||||
# unquoted expansion word-splits cleanly into positional args.
|
||||
# shellcheck disable=SC2046
|
||||
set -- $(node -e 'require(process.argv[1]).forEach((t) => console.log(t.name + "@" + t.version))' "$MANIFEST")
|
||||
if [ "$#" -gt 0 ]; then
|
||||
pnpm install -g "$@"
|
||||
fi
|
||||
+3
-1
@@ -4,6 +4,8 @@ export default defineConfig({
|
||||
test: {
|
||||
// container/agent-runner tests run under Bun (they depend on bun:sqlite).
|
||||
// See container/agent-runner/package.json "test" script.
|
||||
include: ['src/**/*.test.ts', 'setup/**/*.test.ts', 'scripts/**/*.test.ts'],
|
||||
// container/*.test.ts: top-level only — container/agent-runner tests run
|
||||
// under Bun (they depend on bun:sqlite) and must not be picked up here.
|
||||
include: ['src/**/*.test.ts', 'setup/**/*.test.ts', 'scripts/**/*.test.ts', 'container/*.test.ts'],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user